Mirror pixymeta_android library

PiperOrigin-RevId: 613989115
Change-Id: I9ca9a34ce91e85a1077af0ce0c7413fac2f6e323
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e48e096
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,277 @@
+Eclipse Public License - v 2.0
+
+    THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
+    PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
+    OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
+
+1. DEFINITIONS
+
+"Contribution" means:
+
+  a) in the case of the initial Contributor, the initial content
+     Distributed under this Agreement, and
+
+  b) in the case of each subsequent Contributor:
+     i) changes to the Program, and
+     ii) additions to the Program;
+  where such changes and/or additions to the Program originate from
+  and are Distributed by that particular Contributor. A Contribution
+  "originates" from a Contributor if it was added to the Program by
+  such Contributor itself or anyone acting on such Contributor's behalf.
+  Contributions do not include changes or additions to the Program that
+  are not Modified Works.
+
+"Contributor" means any person or entity that Distributes the Program.
+
+"Licensed Patents" mean patent claims licensable by a Contributor which
+are necessarily infringed by the use or sale of its Contribution alone
+or when combined with the Program.
+
+"Program" means the Contributions Distributed in accordance with this
+Agreement.
+
+"Recipient" means anyone who receives the Program under this Agreement
+or any Secondary License (as applicable), including Contributors.
+
+"Derivative Works" shall mean any work, whether in Source Code or other
+form, that is based on (or derived from) the Program and for which the
+editorial revisions, annotations, elaborations, or other modifications
+represent, as a whole, an original work of authorship.
+
+"Modified Works" shall mean any work in Source Code or other form that
+results from an addition to, deletion from, or modification of the
+contents of the Program, including, for purposes of clarity any new file
+in Source Code form that contains any contents of the Program. Modified
+Works shall not include works that contain only declarations,
+interfaces, types, classes, structures, or files of the Program solely
+in each case in order to link to, bind by name, or subclass the Program
+or Modified Works thereof.
+
+"Distribute" means the acts of a) distributing or b) making available
+in any manner that enables the transfer of a copy.
+
+"Source Code" means the form of a Program preferred for making
+modifications, including but not limited to software source code,
+documentation source, and configuration files.
+
+"Secondary License" means either the GNU General Public License,
+Version 2.0, or any later versions of that license, including any
+exceptions or additional permissions as identified by the initial
+Contributor.
+
+2. GRANT OF RIGHTS
+
+  a) Subject to the terms of this Agreement, each Contributor hereby
+  grants Recipient a non-exclusive, worldwide, royalty-free copyright
+  license to reproduce, prepare Derivative Works of, publicly display,
+  publicly perform, Distribute and sublicense the Contribution of such
+  Contributor, if any, and such Derivative Works.
+
+  b) Subject to the terms of this Agreement, each Contributor hereby
+  grants Recipient a non-exclusive, worldwide, royalty-free patent
+  license under Licensed Patents to make, use, sell, offer to sell,
+  import and otherwise transfer the Contribution of such Contributor,
+  if any, in Source Code or other form. This patent license shall
+  apply to the combination of the Contribution and the Program if, at
+  the time the Contribution is added by the Contributor, such addition
+  of the Contribution causes such combination to be covered by the
+  Licensed Patents. The patent license shall not apply to any other
+  combinations which include the Contribution. No hardware per se is
+  licensed hereunder.
+
+  c) Recipient understands that although each Contributor grants the
+  licenses to its Contributions set forth herein, no assurances are
+  provided by any Contributor that the Program does not infringe the
+  patent or other intellectual property rights of any other entity.
+  Each Contributor disclaims any liability to Recipient for claims
+  brought by any other entity based on infringement of intellectual
+  property rights or otherwise. As a condition to exercising the
+  rights and licenses granted hereunder, each Recipient hereby
+  assumes sole responsibility to secure any other intellectual
+  property rights needed, if any. For example, if a third party
+  patent license is required to allow Recipient to Distribute the
+  Program, it is Recipient's responsibility to acquire that license
+  before distributing the Program.
+
+  d) Each Contributor represents that to its knowledge it has
+  sufficient copyright rights in its Contribution, if any, to grant
+  the copyright license set forth in this Agreement.
+
+  e) Notwithstanding the terms of any Secondary License, no
+  Contributor makes additional grants to any Recipient (other than
+  those set forth in this Agreement) as a result of such Recipient's
+  receipt of the Program under the terms of a Secondary License
+  (if permitted under the terms of Section 3).
+
+3. REQUIREMENTS
+
+3.1 If a Contributor Distributes the Program in any form, then:
+
+  a) the Program must also be made available as Source Code, in
+  accordance with section 3.2, and the Contributor must accompany
+  the Program with a statement that the Source Code for the Program
+  is available under this Agreement, and informs Recipients how to
+  obtain it in a reasonable manner on or through a medium customarily
+  used for software exchange; and
+
+  b) the Contributor may Distribute the Program under a license
+  different than this Agreement, provided that such license:
+     i) effectively disclaims on behalf of all other Contributors all
+     warranties and conditions, express and implied, including
+     warranties or conditions of title and non-infringement, and
+     implied warranties or conditions of merchantability and fitness
+     for a particular purpose;
+
+     ii) effectively excludes on behalf of all other Contributors all
+     liability for damages, including direct, indirect, special,
+     incidental and consequential damages, such as lost profits;
+
+     iii) does not attempt to limit or alter the recipients' rights
+     in the Source Code under section 3.2; and
+
+     iv) requires any subsequent distribution of the Program by any
+     party to be under a license that satisfies the requirements
+     of this section 3.
+
+3.2 When the Program is Distributed as Source Code:
+
+  a) it must be made available under this Agreement, or if the
+  Program (i) is combined with other material in a separate file or
+  files made available under a Secondary License, and (ii) the initial
+  Contributor attached to the Source Code the notice described in
+  Exhibit A of this Agreement, then the Program may be made available
+  under the terms of such Secondary Licenses, and
+
+  b) a copy of this Agreement must be included with each copy of
+  the Program.
+
+3.3 Contributors may not remove or alter any copyright, patent,
+trademark, attribution notices, disclaimers of warranty, or limitations
+of liability ("notices") contained within the Program from any copy of
+the Program which they Distribute, provided that Contributors may add
+their own appropriate notices.
+
+4. COMMERCIAL DISTRIBUTION
+
+Commercial distributors of software may accept certain responsibilities
+with respect to end users, business partners and the like. While this
+license is intended to facilitate the commercial use of the Program,
+the Contributor who includes the Program in a commercial product
+offering should do so in a manner which does not create potential
+liability for other Contributors. Therefore, if a Contributor includes
+the Program in a commercial product offering, such Contributor
+("Commercial Contributor") hereby agrees to defend and indemnify every
+other Contributor ("Indemnified Contributor") against any losses,
+damages and costs (collectively "Losses") arising from claims, lawsuits
+and other legal actions brought by a third party against the Indemnified
+Contributor to the extent caused by the acts or omissions of such
+Commercial Contributor in connection with its distribution of the Program
+in a commercial product offering. The obligations in this section do not
+apply to any claims or Losses relating to any actual or alleged
+intellectual property infringement. In order to qualify, an Indemnified
+Contributor must: a) promptly notify the Commercial Contributor in
+writing of such claim, and b) allow the Commercial Contributor to control,
+and cooperate with the Commercial Contributor in, the defense and any
+related settlement negotiations. The Indemnified Contributor may
+participate in any such claim at its own expense.
+
+For example, a Contributor might include the Program in a commercial
+product offering, Product X. That Contributor is then a Commercial
+Contributor. If that Commercial Contributor then makes performance
+claims, or offers warranties related to Product X, those performance
+claims and warranties are such Commercial Contributor's responsibility
+alone. Under this section, the Commercial Contributor would have to
+defend claims against the other Contributors related to those performance
+claims and warranties, and if a court requires any other Contributor to
+pay any damages as a result, the Commercial Contributor must pay
+those damages.
+
+5. NO WARRANTY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
+PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS"
+BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR
+IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF
+TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR
+PURPOSE. Each Recipient is solely responsible for determining the
+appropriateness of using and distributing the Program and assumes all
+risks associated with its exercise of rights under this Agreement,
+including but not limited to the risks and costs of program errors,
+compliance with applicable laws, damage to or loss of data, programs
+or equipment, and unavailability or interruption of operations.
+
+6. DISCLAIMER OF LIABILITY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
+PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS
+SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST
+PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE
+EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+7. GENERAL
+
+If any provision of this Agreement is invalid or unenforceable under
+applicable law, it shall not affect the validity or enforceability of
+the remainder of the terms of this Agreement, and without further
+action by the parties hereto, such provision shall be reformed to the
+minimum extent necessary to make such provision valid and enforceable.
+
+If Recipient institutes patent litigation against any entity
+(including a cross-claim or counterclaim in a lawsuit) alleging that the
+Program itself (excluding combinations of the Program with other software
+or hardware) infringes such Recipient's patent(s), then such Recipient's
+rights granted under Section 2(b) shall terminate as of the date such
+litigation is filed.
+
+All Recipient's rights under this Agreement shall terminate if it
+fails to comply with any of the material terms or conditions of this
+Agreement and does not cure such failure in a reasonable period of
+time after becoming aware of such noncompliance. If all Recipient's
+rights under this Agreement terminate, Recipient agrees to cease use
+and distribution of the Program as soon as reasonably practicable.
+However, Recipient's obligations under this Agreement and any licenses
+granted by Recipient relating to the Program shall continue and survive.
+
+Everyone is permitted to copy and distribute copies of this Agreement,
+but in order to avoid inconsistency the Agreement is copyrighted and
+may only be modified in the following manner. The Agreement Steward
+reserves the right to publish new versions (including revisions) of
+this Agreement from time to time. No one other than the Agreement
+Steward has the right to modify this Agreement. The Eclipse Foundation
+is the initial Agreement Steward. The Eclipse Foundation may assign the
+responsibility to serve as the Agreement Steward to a suitable separate
+entity. Each new version of the Agreement will be given a distinguishing
+version number. The Program (including Contributions) may always be
+Distributed subject to the version of the Agreement under which it was
+received. In addition, after a new version of the Agreement is published,
+Contributor may elect to Distribute the Program (including its
+Contributions) under the new version.
+
+Except as expressly stated in Sections 2(a) and 2(b) above, Recipient
+receives no rights or licenses to the intellectual property of any
+Contributor under this Agreement, whether expressly, by implication,
+estoppel or otherwise. All rights in the Program not expressly granted
+under this Agreement are reserved. Nothing in this Agreement is intended
+to be enforceable by any entity that is not a Contributor or Recipient.
+No third-party beneficiary rights are created under this Agreement.
+
+Exhibit A - Form of Secondary Licenses Notice
+
+"This Source Code may also be made available under the following
+Secondary Licenses when the conditions for such availability set forth
+in the Eclipse Public License, v. 2.0 are satisfied: {name license(s),
+version(s), and exceptions or additional permissions here}."
+
+  Simply including a copy of this Agreement, including this Exhibit A
+  is not sufficient to license the Source Code under Secondary Licenses.
+
+  If it is not possible or desirable to put the notice in a particular
+  file, then You may include the notice in a location (such as a LICENSE
+  file in a relevant directory) where a recipient would be likely to
+  look for such a notice.
+
+  You may add additional accurate notices of copyright ownership.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0b8c4a6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,64 @@
+# pixymeta-android
+Standalone Android version of PixyMeta - a pure Java image metadata manipulation tool.
+
+Image metadata manipulation:
+----------------------------------------
+- JPEG and TIFF EXIF data manipulation
+   * Insert EXIF data into JPEG.
+   * Extract EXIF data from JPEG.
+   * Remove EXIF data and other insignificant APPn segments from JPEG.
+   * Insert EXIF data into TIFF.
+   * Read EXIF data embedded in TIFF.
+- JPEG and TIFF ICC Profile support
+   * Insert ICC profile to JPEG and TIFF.
+   * Extract ICC profile from JPEG and TIFF.
+- JPEG and TIFF IPTC metadata support
+   * Insert IPTC directly to TIFF via RichTiffIPTC tag.
+   * Insert IPTC to JPEG via APP13 Photoshop IRB
+   * Extract IPTC from both TIFF and JPEG
+- JPEG and TIFF Photoshop IRB metadata support
+   * Insert IRB into JPEG via APP13 segment
+   * Insert IRB into TIFF via tag PHOTOSHOP.
+   * Extract IRB data from both JPEG and TIFF.
+- JPEG, GIF, PNG, TIFF XMP metadata support
+   * Insert XMP metada into JPEG, GIF, PNG, and TIFF image
+   * Extract XMP metadata from JPEG, GIF, PNG, and TIFF image
+   * In case of JPEG, handle normal XMP and extendedXMP which cannot fit into one APP1 segment
+
+Where can I get the latest release?
+-----------------------------------
+There is currently no stable release of PIXYMETA-ANDROID. However you can pull the latest SNAPSHOT from Sonatype SNAPSHOT repository by adding the snapshot repository to your pom.xml:
+ 
+```xml
+<repository>
+  <id>oss.sonatype.org</id>
+  <name>Sonatype Snapshot Repository</name>
+  <url>https://oss.sonatype.org/content/repositories/snapshots</url>
+  <releases>
+    <enabled>false</enabled>
+  </releases>
+  <snapshots>
+    <enabled>true</enabled>
+  </snapshots>
+</repository> 
+```
+
+Then you can use the SNAPSHOT version of ICAFE in your pom.xml:
+
+```xml
+<dependency>
+  <groupId>com.github.dragon66</groupId>
+  <artifactId>pixymeta-android</artifactId>
+  <version>1.0-SNAPSHOT</version>
+</dependency>
+``` 
+
+Go to the [wiki] page to see this library in action or grab the "pixymeta-android.jar" from the lib folder and try it yourself!
+
+[wiki]:https://github.com/dragon66/pixymeta-android/wiki
+[Open]:https://github.com/dragon66/pixymeta-android/issues/new
+Tested on Android Nexus4 virtual device only!!!
+
+[Project using pixymeta-android](https://github.com/CreativeCommons-Seneca/cc-xmp-tag)
+
+Suggestions? custom requirements? [Open] an issue or send email to me directly: yuwen_66@yahoo.com
diff --git a/src/log4j.properties b/src/log4j.properties
new file mode 100644
index 0000000..3c86394
--- /dev/null
+++ b/src/log4j.properties
@@ -0,0 +1,11 @@
+# Set root logger level to DEBUG and its only appender to A1.
+log4j.rootLogger=INFO, A1
+
+# A1 is set to be a ConsoleAppender.
+log4j.appender.A1=org.apache.log4j.ConsoleAppender
+
+# A1 uses PatternLayout.
+log4j.appender.A1.layout=org.apache.log4j.PatternLayout
+
+# Print the date in ISO 8601 format
+log4j.appender.A1.layout.ConversionPattern=%d [%t] %-5p %c - %m%n
\ No newline at end of file
diff --git a/src/pixy/image/ImageType.java b/src/pixy/image/ImageType.java
new file mode 100644
index 0000000..66c2255
--- /dev/null
+++ b/src/pixy/image/ImageType.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image;
+
+import java.util.Map;
+import java.util.HashMap;
+
+/**
+ * Image types supported by ImageReader and ImageWriter.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 10/09/2012
+ */
+public enum ImageType {
+	
+	GIF("Gif") { 
+		@Override
+		public String getExtension() {
+			return "gif";
+		}		
+	},
+	
+    PNG("Png") { 
+		@Override
+		public String getExtension() {
+			return "png";
+		}
+	},
+	
+    JPG("Jpeg") { 
+		@Override
+		public String getExtension() {
+			return "jpg";
+		}
+	},
+	
+	JPG2000("Jpeg2000") {
+		@Override
+		public String getExtension() {
+			return "jp2";
+		}
+	},
+	
+    BMP("Bitmap") { 
+		@Override
+		public String getExtension() {
+			return "bmp";
+		}
+	},
+	
+    TGA("Targa") { 
+		@Override
+		public String getExtension() {
+			return "tga";
+		}
+	},
+	
+	TIFF("Tiff") { 
+		@Override
+		public String getExtension() {
+			return "tif";
+		}
+	},
+	
+    PCX("Pcx") { 
+		@Override
+		public String getExtension() {
+			return "pcx";
+		}
+	},
+	
+	UNKNOWN("Unknown") {
+		@Override
+		public String getExtension() {
+			return null;
+		}
+	};
+    
+    private static final Map<String, ImageType> stringMap = new HashMap<String, ImageType>();
+   
+    static
+    {
+      for(ImageType type : values())
+          stringMap.put(type.toString(), type);
+    }
+   
+    public static ImageType fromString(String name)
+    {
+      return stringMap.get(name);
+    }
+   
+    private final String name;
+   
+    private ImageType(String name)
+    {
+      this.name = name;
+    }
+    
+    public abstract String getExtension();
+
+    @Override
+    public String toString()
+    {
+      return name;
+    }
+}
diff --git a/src/pixy/image/bmp/BmpCompression.java b/src/pixy/image/bmp/BmpCompression.java
new file mode 100644
index 0000000..8400426
--- /dev/null
+++ b/src/pixy/image/bmp/BmpCompression.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.bmp;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * BMP compression type.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 05/09/2014
+ */
+public enum BmpCompression {
+	//
+	BI_RGB("No Compression", 0),
+	BI_RLE8("8 bit RLE Compression (8 bit only)", 1),
+	BI_RLE4("4 bit RLE Compression (4 bit only)", 2),
+	BI_BITFIELDS("No compression (16 & 32 bit only)", 3),
+	
+	UNKNOWN("Unknown", 9999);
+	
+	private BmpCompression(String description, int value) {
+		this.description = description;
+		this.value = value;
+	}
+	
+	public String getDescription() {
+		return description;
+	}
+	
+	public int getValue() {
+		return value;
+	}
+	
+	@Override
+    public String toString() {
+		return description;
+	}
+	
+	public static BmpCompression fromInt(int value) {
+       	BmpCompression compression = typeMap.get(value);
+    	if (compression == null)
+    	   return UNKNOWN;
+      	return compression;
+    }
+    
+    private static final Map<Integer, BmpCompression> typeMap = new HashMap<Integer, BmpCompression>();
+       
+    static
+    {
+      for(BmpCompression compression : values())
+    	  typeMap.put(compression.getValue(), compression);
+    } 
+
+	private String description;
+	private int value;
+}
diff --git a/src/pixy/image/gif/ApplicationExtension.java b/src/pixy/image/gif/ApplicationExtension.java
new file mode 100644
index 0000000..797e955
--- /dev/null
+++ b/src/pixy/image/gif/ApplicationExtension.java
@@ -0,0 +1,53 @@
+package pixy.image.gif;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * GIF Application Extension wrapper
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/14/2015
+ */
+public class ApplicationExtension {
+	// Sequence of eight printable ASCII characters used to identify
+	// the application owning the Application Extension.
+	private byte[] applicationId; // 8 byte
+	// Sequence of three bytes used to authenticate the Application Identifier
+	private byte[] authenticationCode; // 3 byte
+	private byte[] data;
+	
+	public static final byte EXTENSION_INTRODUCER = 0x21;
+	public static final byte EXTENSION_LABEL = (byte)0xFF; 
+	// Number of bytes in this extension block, following the Block Size field,
+	// up to but not including the beginning of the Application Data.
+	// This field contains the fixed value 11.
+	public static final byte BLOCK_SIZE = 11; //
+	
+	public ApplicationExtension(byte[] applicationId, byte[] authenticationCode, byte[] data) {
+		this.applicationId = applicationId;
+		this.authenticationCode = authenticationCode;
+		this.data = data;
+	}
+	
+	public byte[] getApplicationId() {
+		return applicationId;
+	}
+	
+	public byte[] getAuthenticationCode() {
+		return authenticationCode;
+	}
+	
+	public byte[] getData() {
+		return data;
+	}
+	
+	public void write(OutputStream os) throws IOException {
+		os.write(EXTENSION_INTRODUCER);
+		os.write(EXTENSION_LABEL);
+		os.write(BLOCK_SIZE);
+		os.write(applicationId);
+		os.write(authenticationCode);
+		os.write(data);
+	}
+}
diff --git a/src/pixy/image/jpeg/COMBuilder.java b/src/pixy/image/jpeg/COMBuilder.java
new file mode 100644
index 0000000..b229373
--- /dev/null
+++ b/src/pixy/image/jpeg/COMBuilder.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.jpeg;
+
+/**
+ * JPEG COM segment builder
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 10/11/2013
+ */
+
+public class COMBuilder extends SegmentBuilder {
+
+	private String comment;
+	
+	public COMBuilder() {
+		super(Marker.COM);	
+	}
+	
+	public COMBuilder comment(String comment) {
+		this.comment = comment;
+		return this;
+	}
+	
+	@Override
+	protected byte[] buildData() {
+		return comment.getBytes();
+	}
+}
diff --git a/src/pixy/image/jpeg/COMReader.java b/src/pixy/image/jpeg/COMReader.java
new file mode 100644
index 0000000..b0af04e
--- /dev/null
+++ b/src/pixy/image/jpeg/COMReader.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.jpeg;
+
+import java.io.IOException;
+
+import pixy.util.Reader;
+
+/**
+ * JPEG COM segment reader
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 10/11/2013
+ */
+public class COMReader implements Reader {
+
+	private Segment segment;
+	private String comment;
+	
+	public COMReader(Segment segment) throws IOException {
+		//
+		if(segment.getMarker() != Marker.COM) {
+			throw new IllegalArgumentException("Not a valid COM segment!");
+		}
+		
+		this.segment = segment;
+		read();
+	}
+	
+	public String getComment() {
+		return this.comment;
+	}
+	
+	public void read() throws IOException {
+		this.comment = new String(segment.getData()).trim();
+	}
+}
diff --git a/src/pixy/image/jpeg/Component.java b/src/pixy/image/jpeg/Component.java
new file mode 100644
index 0000000..eefc643
--- /dev/null
+++ b/src/pixy/image/jpeg/Component.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.jpeg;
+
+/**
+ * Encapsulates a JPEG sample component
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 10/09/2013
+ */
+public class Component {
+	//
+	private byte id;
+	private byte hSampleFactor;
+	private byte vSampleFactor;
+	private byte qTableNumber;
+	
+	private byte acTableNumber;
+	private byte dcTableNumber;
+	
+	Component(byte id, byte hSampleFactor, byte vSampleFactor, byte qTableNumber) {
+		this.id = id;
+		this.hSampleFactor = hSampleFactor;
+		this.vSampleFactor = vSampleFactor;
+		this.qTableNumber = qTableNumber;
+	}
+	
+	public byte getACTableNumber() {
+		return acTableNumber;
+	}
+	
+	public byte getDCTableNumber() {
+		return dcTableNumber;
+	}
+	
+	public byte getId() {
+		return id;		
+	}
+	
+	public byte getHSampleFactor() {
+		return hSampleFactor;
+	}
+	
+	public byte getVSampleFactor() {
+		return vSampleFactor;
+	}
+	
+	public byte getQTableNumber() {
+		return qTableNumber;
+	}
+	
+	public void setACTableNumber(byte acTableNumber) {
+		this.acTableNumber = acTableNumber;
+	}
+	
+	public void setDCTableNumber(byte dcTableNumber) {
+		this.dcTableNumber = dcTableNumber;
+	}
+}
diff --git a/src/pixy/image/jpeg/DHTReader.java b/src/pixy/image/jpeg/DHTReader.java
new file mode 100644
index 0000000..a76a304
--- /dev/null
+++ b/src/pixy/image/jpeg/DHTReader.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.jpeg;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.util.Reader;
+
+/**
+ * JPEG DHT segment reader
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 10/12/2013
+ */
+public class DHTReader implements Reader {
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(DHTReader.class);
+		
+	private Segment segment;
+	private List<HTable> dcTables = new ArrayList<HTable>(3);
+	private List<HTable> acTables = new ArrayList<HTable>(3);
+	
+	public DHTReader(Segment segment) throws IOException {
+		//
+		if(segment.getMarker() != Marker.DHT) {
+			throw new IllegalArgumentException("Not a valid DHT segment!");
+		}
+		
+		this.segment = segment;
+		read();
+	}
+	
+	public List<HTable> getDCTables() {
+		return dcTables;
+	}
+		
+	public List<HTable> getACTables() {
+		return acTables;
+	}	
+	
+	public void read() throws IOException {
+		//
+		byte[] data = segment.getData();		
+		int len = segment.getLength();
+		len -= 2;//
+		
+		int offset = 0;
+		
+		while (len > 0)
+		{
+			int HT_info = data[offset++];
+			
+			int HT_class = (HT_info>>4)&0x01;// 0=DC table, 1=AC table
+			int HT_destination_id = (HT_info&0x0f);// Huffman tables number
+			byte[] bits = new byte[16];
+			byte[] values;
+           
+			int count = 0;
+			
+			for (int i = 0; i < 16; i++)
+			{
+				bits[i] = data[offset + i];
+				count += (bits[i]&0xff);
+			}
+						
+            if (count > 256)
+			{
+				LOGGER.error("invalid huffman code count!");			
+				return;
+			}
+            
+            offset += 16;
+            
+            values = new byte[count];
+			
+      		for (int i=0; i<count; i++)
+			{
+                values[i] = data[offset + i];
+			}
+      		
+      		offset += count;			
+			len -= (1+16+count);
+			
+			HTable table = new HTable(HT_class, HT_destination_id, bits, values);
+			
+			if(HT_class == HTable.DC_CLAZZ) {
+				dcTables.add(table);
+			}
+			else if(HT_class == HTable.AC_CLAZZ) {
+				acTables.add(table);
+			}
+			else {
+				LOGGER.error("Invalid component class value: " + HT_class);
+				return;
+			}			
+		}
+	}
+}
diff --git a/src/pixy/image/jpeg/DQTReader.java b/src/pixy/image/jpeg/DQTReader.java
new file mode 100644
index 0000000..553e5bf
--- /dev/null
+++ b/src/pixy/image/jpeg/DQTReader.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.jpeg;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import pixy.io.IOUtils;
+import pixy.util.Reader;
+
+/**
+ * JPEG DQT segment reader
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 10/11/2013
+ */
+public class DQTReader implements Reader {
+
+	private Segment segment;
+	private List<QTable> qTables = new ArrayList<QTable>(4);
+	
+	public DQTReader(Segment segment) throws IOException {
+		//
+		if(segment.getMarker() != Marker.DQT) {
+			throw new IllegalArgumentException("Not a valid DQT segment!");
+		}
+		
+		this.segment = segment;
+		read();
+	}
+	
+	public List<QTable> getTables() {
+		return qTables;
+	}
+	
+	public void read() throws IOException {
+		//
+		byte[] data = segment.getData();		
+		int len = segment.getLength();
+		len -= 2;//
+		
+		int offset = 0;
+		
+	  	int[] de_zig_zag_order = JPGConsts.getDeZigzagMatrix();
+		  
+		while(len > 0)
+		{
+			int QT_info = data[offset++];
+			len--;
+		    int QT_precision = (QT_info>>4)&0x0f;
+		    int QT_index=(QT_info&0x0f);
+		    int numOfValues = 64 << QT_precision;
+		    
+		    int[] out = new int[64];
+		   
+		    // Read QT tables
+    	    // 8 bit For precision value of 0
+		   	if(QT_precision == 0) {
+				for (int j = 0; j < 64; j++) {
+					out[j] = data[de_zig_zag_order[j] + offset]&0xff;			
+			    }
+			} else { // 16 bit big-endian for precision value of 1								
+				for (int j = 0; j < 64; j++) {
+					out[j] = (IOUtils.readUnsignedShortMM(data, offset + de_zig_zag_order[j]<<1));	
+				}				
+			}
+		   	
+		   	qTables.add(new QTable(QT_precision, QT_index, out));
+		
+			len -= numOfValues;
+			offset += numOfValues;
+		}
+	}	
+}
diff --git a/src/pixy/image/jpeg/HTable.java b/src/pixy/image/jpeg/HTable.java
new file mode 100644
index 0000000..3b85a52
--- /dev/null
+++ b/src/pixy/image/jpeg/HTable.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.jpeg;
+
+public class HTable implements Comparable<HTable> {
+	//
+	public static final int DC_CLAZZ = 0;
+	public static final int AC_CLAZZ = 1;
+	
+	private int clazz; // DC or AC
+	private int id; // Table #
+	private byte[] bits;
+	private byte[] values;
+	
+	public HTable(int clazz, int id, byte[] bits, byte[] values) {
+		this.clazz = clazz;
+		this.id = id;
+		this.bits = bits;
+		this.values = values;
+	}
+	
+	public int getClazz() {
+		return clazz; 
+	}
+	
+	public int getID() {
+		return id;
+	}
+	
+	public byte[] getBits() {
+		return bits;
+	}
+	
+	public byte[] getValues() {
+		return values;
+	}
+
+	public int compareTo(HTable that) {
+		return this.id - that.id;
+	}
+}
diff --git a/src/pixy/image/jpeg/JPGConsts.java b/src/pixy/image/jpeg/JPGConsts.java
new file mode 100644
index 0000000..5c9255e
--- /dev/null
+++ b/src/pixy/image/jpeg/JPGConsts.java
@@ -0,0 +1,237 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.jpeg;
+
+/**
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/16/2012
+ */
+public class JPGConsts {
+	// Constants related to metadata
+	public static final String XMP_ID = "http://ns.adobe.com/xap/1.0/\0";
+	// This is a non_standard XMP identifier which sometimes found in images from GettyImages
+	public static final String NON_STANDARD_XMP_ID = "XMP\0://ns.adobe.com/xap/1.0/\0";
+	public static final String XMP_EXT_ID = "http://ns.adobe.com/xmp/extension/\0";
+	// Photoshop IRB identification with trailing byte [0x00].
+	public static final String PHOTOSHOP_IRB_ID = "Photoshop 3.0\0";
+	// EXIF identifier with trailing bytes [0x00, 0x00].
+	public static final String EXIF_ID = "Exif\0\0";
+	// ICC_PROFILE identifier with trailing byte [0x00].
+	public static final String ICC_PROFILE_ID = "ICC_PROFILE\0";
+	public static final String JFIF_ID = "JFIF\0"; // JFIF
+	public static final String JFXX_ID = "JFXX\0"; // JFXX
+	public static final String DUCKY_ID = "Ducky"; // no trailing NULL
+	public static final String PICTURE_INFO_ID = "[picture info]"; // no trailing NULL
+	public static final String ADOBE_ID = "Adobe"; // no trailing NULL
+	
+	public static final int SUBSAMPLING_NONE = 0; // aka 1x1 (4:4:4)
+	public static final int SUBSAMPLING_422  = 1; // aka 2x1
+	public static final int SUBSAMPLING_420  = 2; // aka 2x2
+	
+   /**
+    * This is the order in which the after-DCT 8x8 block is traversed 
+    * used for reordering the QT(quantization Table) and the 
+    * zigzag traversed blocks
+	*/
+   private static final int[] ZIGZAG_TRAVERSE_ORDER = {
+	   0,  1,  8, 16,  9,  2,  3, 10,
+      17, 24, 32, 25, 18, 11,  4,  5,
+      12, 19, 26, 33, 40, 48, 41, 34,
+      27, 20, 13,  6,  7, 14, 21, 28,
+      35, 42, 49, 56, 57, 50, 43, 36,
+      29, 22, 15, 23, 30, 37, 44, 51,
+      58, 59, 52, 45, 38, 31, 39, 46,
+      53, 60, 61, 54, 47, 55, 62, 63
+   };
+   
+   // Reverses ZigZag reordering of the quantization Table
+   private static final int[] DE_ZIGZAG_TRAVERSE_ORDER = {
+       0,  1,  5,  6, 14, 15, 27, 28,
+       2,  4,  7, 13, 16, 26, 29, 42,
+       3,  8, 12, 17, 25, 30, 41, 43,
+       9, 11, 18, 24, 31, 40, 44, 53,
+      10, 19, 23, 32, 39, 45, 52, 54,
+      20, 22, 33, 38, 46, 51, 55, 60,
+      21, 34, 37, 47, 50, 56, 59, 61,
+      35, 36, 48, 49, 57, 58, 62, 63
+   };
+   
+   /**
+    *  This is the default quantization table for luminance
+    *  ISO/IEC 10918-1 : 1993(E), Annex, Table K.1
+    */
+   private static final int[] QUANT_LUMINANCE = {
+      16, 11, 10, 16, 24, 40, 51, 61,
+      12, 12, 14, 19, 26, 58, 60, 55,
+      14, 13, 16, 24, 40, 57, 69, 56,
+      14, 17, 22, 29, 51, 87, 80, 62,
+      18, 22, 37, 56, 68, 109, 103, 77,
+      24, 35, 55, 64, 81, 104, 113, 92,
+      49, 64, 78, 87, 103, 121, 120, 101,
+      72, 92, 95, 98, 112, 100, 103, 99
+   };
+   
+   /**
+    *  This is the default quantization table for chrominance
+    *  ISO/IEC 10918-1 : 1993(E), Annex, KTable K.2
+    */
+   private static final int[] QUANT_CHROMINANCE = {
+      17, 18, 24, 47, 99, 99, 99, 99,
+      18, 21, 26, 66, 99, 99, 99, 99,
+      24, 26, 56, 99, 99, 99, 99, 99,
+      47, 66, 99, 99, 99, 99, 99, 99,
+      99, 99, 99, 99, 99, 99, 99, 99,
+      99, 99, 99, 99, 99, 99, 99, 99,
+      99, 99, 99, 99, 99, 99, 99, 99,
+      99, 99, 99, 99, 99, 99, 99, 99
+   };  
+   
+   private static final byte[] DC_LUMINANCE_BITS = {0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0};
+   
+   private static final byte[] DC_LUMINANCE_VALUES = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b};
+   
+   private static final byte[] DC_CHROMINANCE_BITS = {0, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0};
+   
+   private static final byte[] DC_CHROMINANCE_VALUES = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b};
+   
+   private static final byte[] AC_LUMINANCE_BITS = {0, 2, 1, 3, 3, 2, 4, 3, 5, 5, 4, 4, 0, 0, 1, 125};
+   
+   private static final byte[] AC_LUMINANCE_VALUES = {0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
+	   0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, (byte)0x81, (byte)0x91, (byte)0xA1,
+	   0x08, 0x23, 0x42, (byte)0xB1, (byte)0xC1, 0x15, 0x52, (byte)0xD1, (byte)0xF0, 0x24, 0x33, 0x62, 0x72,
+	   (byte)0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35,
+	   0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56,
+	   0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77,
+	   0x78, 0x79, 0x7A, (byte)0x83, (byte)0x84, (byte)0x85, (byte)0x86, (byte)0x87, (byte)0x88, (byte)0x89,
+	   (byte)0x8A, (byte)0x92, (byte)0x93, (byte)0x94, (byte)0x95, (byte)0x96, (byte)0x97, (byte)0x98, 
+	   (byte)0x99, (byte)0x9A, (byte)0xA2, (byte)0xA3, (byte)0xA4, (byte)0xA5, (byte)0xA6, (byte)0xA7,
+	   (byte)0xA8, (byte)0xA9, (byte)0xAA, (byte)0xB2, (byte)0xB3, (byte)0xB4, (byte)0xB5, (byte)0xB6,
+	   (byte)0xB7, (byte)0xB8, (byte)0xB9, (byte)0xBA, (byte)0xC2, (byte)0xC3, (byte)0xC4, (byte)0xC5,
+	   (byte)0xC6, (byte)0xC7, (byte)0xC8, (byte)0xC9, (byte)0xCA, (byte)0xD2, (byte)0xD3, (byte)0xD4,
+	   (byte)0xD5, (byte)0xD6, (byte)0xD7, (byte)0xD8, (byte)0xD9, (byte)0xDA, (byte)0xE1, (byte)0xE2,
+	   (byte)0xE3, (byte)0xE4, (byte)0xE5, (byte)0xE6, (byte)0xE7, (byte)0xE8, (byte)0xE9, (byte)0xEA,
+	   (byte)0xF1, (byte)0xF2, (byte)0xF3, (byte)0xF4, (byte)0xF5, (byte)0xF6, (byte)0xF7, (byte)0xF8,
+	   (byte)0xF9, (byte)0xFA
+   };
+   
+   private static final byte[] AC_CHROMINANCE_BITS = {0, 2, 1, 2, 4, 4, 3, 4, 7, 5, 4, 4, 0, 1, 2, 119};
+   
+   private static final byte[] AC_CHROMINANCE_VALUES = {0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21,
+	   0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, (byte)0x32, (byte)0x81, (byte)0x08,
+	   0x14, 0x42, (byte)0x91, (byte)0xA1, (byte)0xB1, (byte)0xC1, 0x09, 0x23, 0x33, 0x52, (byte)0xF0,
+	   0x15, 0x62, 0x72, (byte)0xD1, 0x0A, 0x16, 0x24, 0x34, (byte)0xE1, 0x25, (byte)0xF1,
+	   0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43,
+	   0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63,
+	   0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A,
+	   (byte)0x82, (byte)0x83, (byte)0x84, (byte)0x85, (byte)0x86, (byte)0x87, (byte)0x88, (byte)0x89,
+	   (byte)0x8A, (byte)0x92, (byte)0x93, (byte)0x94, (byte)0x95, (byte)0x96, (byte)0x97, (byte)0x98,
+	   (byte)0x99, (byte)0x9A, (byte)0xA2, (byte)0xA3, (byte)0xA4, (byte)0xA5, (byte)0xA6, (byte)0xA7,
+	   (byte)0xA8, (byte)0xA9, (byte)0xAA, (byte)0xB2, (byte)0xB3, (byte)0xB4, (byte)0xB5, (byte)0xB6,
+	   (byte)0xB7, (byte)0xB8, (byte)0xB9, (byte)0xBA, (byte)0xC2, (byte)0xC3, (byte)0xC4, (byte)0xC5,
+	   (byte)0xC6, (byte)0xC7, (byte)0xC8, (byte)0xC9, (byte)0xCA, (byte)0xD2, (byte)0xD3, (byte)0xD4,
+	   (byte)0xD5, (byte)0xD6, (byte)0xD7, (byte)0xD8, (byte)0xD9, (byte)0xDA, (byte)0xE2, (byte)0xE3,
+	   (byte)0xE4, (byte)0xE5, (byte)0xE6, (byte)0xE7, (byte)0xE8, (byte)0xE9, (byte)0xEA, (byte)0xF2,
+	   (byte)0xF3, (byte)0xF4, (byte)0xF5, (byte)0xF6, (byte)0xF7, (byte)0xF8, (byte)0xF9, (byte)0xFA
+   };
+   
+   public static final byte[] getACChrominanceBits() {
+	   return AC_CHROMINANCE_BITS.clone();
+   }
+   
+   public static final byte[] getACChrominanceValues() {
+	   return AC_CHROMINANCE_VALUES.clone();
+   }
+   
+   public static final byte[] getACLuminanceBits() {
+	   return AC_LUMINANCE_BITS.clone();
+   }
+   
+   public static final byte[] getACLuminanceValues() {
+	   return AC_LUMINANCE_VALUES.clone();
+   }
+   
+   public static final byte[] getDCChrominanceBits() {
+	   return DC_CHROMINANCE_BITS.clone();
+   }
+   
+   public static final byte[] getDCChrominanceValues() {
+	   return DC_CHROMINANCE_VALUES.clone();
+   }
+   
+   public static final byte[] getDCLuminanceBits() {
+	   return DC_LUMINANCE_BITS.clone();
+   }
+   
+   public static final byte[] getDCLuminanceValues() {
+	   return DC_LUMINANCE_VALUES.clone();
+   }
+   
+   public static final int[] getDefaultChrominanceMatrix(int quality) {
+	   //
+	   int[] quant_chrominance = QUANT_CHROMINANCE.clone();
+	   
+	   if (quality <= 0)
+           quality = 1;
+	   if (quality > 100)
+           quality = 100;
+	   if (quality < 50)
+           quality = 5000 / quality;
+	   else
+           quality = 200 - quality * 2;
+	   
+	   for (int j = 0; j < 64; j++) {
+               int temp = (quant_chrominance[j] * quality + 50) / 100;
+               if ( temp <= 0) temp = 1;
+               if (temp >= 255) temp = 255;
+               quant_chrominance[j] = temp;
+       }
+	   
+	   return quant_chrominance;
+   }
+   
+   public static final int[] getDefaultLuminanceMatrix(int quality) {
+	   //
+	   int[] quant_luminance = QUANT_LUMINANCE.clone();
+	   
+	   if (quality <= 0)
+           quality = 1;
+	   if (quality > 100)
+           quality = 100;
+	   if (quality < 50)
+           quality = 5000 / quality;
+	   else
+           quality = 200 - quality * 2;
+	   
+	   for (int j = 0; j < 64; j++) {
+               int temp = (quant_luminance[j] * quality + 50) / 100;
+               if ( temp <= 0) temp = 1;
+               if (temp >= 255) temp = 255;
+               quant_luminance[j] = temp;
+       }
+	   
+	   return quant_luminance;
+   }
+   
+   public static final int[] getDeZigzagMatrix() {
+	   return DE_ZIGZAG_TRAVERSE_ORDER.clone();
+   }
+   
+   public static final int[] getZigzagMatrix() {
+	   return ZIGZAG_TRAVERSE_ORDER.clone();
+   }
+   
+   private JPGConsts() {}
+}  
diff --git a/src/pixy/image/jpeg/Marker.java b/src/pixy/image/jpeg/Marker.java
new file mode 100644
index 0000000..03ea71b
--- /dev/null
+++ b/src/pixy/image/jpeg/Marker.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.jpeg;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Class represents JPEG marker.
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/08/2013
+ */
+public enum Marker {
+
+	    /**
+	     * Define JPEG markers. 
+	     * A marker is prefixed by a one byte segment identifier 0xff. 
+	     * Most markers will have additional information following them. 
+	     * When this is the case, the marker and its associated information
+	     * is referred to as a "header." In a header the marker is immediately 
+	     * followed by two bytes that indicate the length of the information, 
+	     * in bytes, that the header contains. The two bytes that indicate the 
+	     * length are always included in that count.
+	     */
+	    TEM ("Temporary private use by arithmetic encoders", (short)0xff01),
+	    SOF0("Baseline DCT", (short)0xffc0),
+	    SOF1("Extended sequential DCT, Huffman coding", (short)0xffc1),
+	    SOF2("Progressive DCT, Huffman coding", (short)0xffc2),
+	    SOF3("Lossless, Huffman coding", (short)0xffc3),
+	    DHT("Define Huffman table", (short)0xffc4),
+	    SOF5("Differential sequential DCT, Huffman coding", (short)0xffc5),
+	    SOF6("Differential progressive DCT, Huffman coding", (short)0xffc6),
+	    SOF7("Differential lossless, Huffman coding", (short)0xffc7),
+	    JPG("Reserved", (short)0xffc8), 
+	    SOF9("Sequential DCT, arithmetic coding", (short)0xffc9), 
+	    SOF10("Progressive DCT, arithmetic coding", (short)0xffca), 
+	    SOF11("Lossless, arithmetic coding", (short)0xffcb),
+	    DAC("Define Arithmetic Table ", (short)0xffcc), 
+	    SOF13("Differential sequential DCT, arithmetic coding", (short)0xffcd), 
+	    SOF14("Differential progressive DCT, arithmetic coding", (short)0xffce), 
+	    SOF15("Differential lossless, arithmetic coding", (short)0xffcf),
+	    /**
+	     * RSTn are used for resync, may be ignored, no length and other contents are
+	     * associated with these markers. 
+	     */
+	    RST0("Restart 0", (short)0xffd0),  
+	    RST1("Restart 1", (short)0xffd1),
+	    RST2("Restart 2", (short)0xffd2),
+	    RST3("Restart 3", (short)0xffd3),
+	    RST4("Restart 4", (short)0xffd4),
+	    RST5("Restart 5", (short)0xffd5),
+	    RST6("Restart 6", (short)0xffd6),
+	    RST7("Restart 7", (short)0xffd7),
+	    //End of RSTn definitions
+	    SOI("Start of image", (short)0xffd8),
+	    EOI("End of image", (short)0xffd9),
+	    SOS("Start of scan", (short)0xffda),
+	    DQT("Define quantization table", (short)0xffdb),
+	    DNL("Define number of lines", (short)0xffdc),
+	    DRI("Define restart interval", (short)0xffdd),
+	    DHP("Define hierarchical progression", (short)0xffde),
+	    EXP("Expand reference components", (short)0xffdf),
+	    APP0("JFIF/JFXX/CIFF/AVI1", (short)0xffe0),
+	    APP1("EXIF/XMP/ExtendedXMP", (short)0xffe1),
+	    APP2("FPXR/ICC profile/MPF/PreviewImage", (short)0xffe2), 
+	    APP3("Meta/Stim", (short)0xffe3), 
+	    APP4("Scalado", (short)0xffe4), 
+	    APP5("RMETA", (short)0xffe5), 
+	    APP6("EPPIM/NITF", (short)0xffe6), 
+	    APP7("", (short)0xffe7), 
+	    APP8("SPIFF", (short)0xffe8), 
+	    APP9("", (short)0xffe9), 
+	    APP10("Comment", (short)0xffea), 
+	    APP11("", (short)0xffeb), 
+	    APP12("Photoshop Ducky/PictureInfo", (short)0xffec), 
+	    APP13("Photoshop IRB/Adobe_CM", (short)0xffed),
+	    APP14("Adobe DCT encoding information", (short)0xffee), 
+	    APP15("GraphicConverter", (short)0xffef), 
+	    JPG0("Reserved", (short)0xfff0),
+	    // A lot more here ...
+	    JPG13("Reserved", (short)0xfffd),
+	    COM("Comment", (short)0xfffe),
+	    // End of JPEG marker definitions
+	    // Special case of arbitrary padding 0xff after segment identifier.
+	    PADDING("Padding", (short)0xffff),
+	    // Special case of unknown segment identifier.
+	    UNKNOWN("Unknown", (short)0x0000);
+	 	    
+	    private Marker(String description, short value) {
+			this.description = description;
+			this.value = value;
+	    }
+		
+	    public enum Attribute {
+			STAND_ALONE,
+			MARKER_SEGMENT;		
+	    }
+	    
+	    public String getDescription() {
+		   return description;
+	    }
+	   
+	    public short getValue() {
+		   return value;
+	    }
+	    
+	    public static Marker fromShort(short value) {
+	       	Marker marker = markerMap.get(value);
+	    	if (marker == null)
+	    	   return UNKNOWN;
+	      	return marker;
+	    }
+	   
+	    @Override public String toString() {
+		   return name() + ": " + description;
+	    }
+	   
+	    private static final Map<Short, Marker> markerMap = new HashMap<Short, Marker>();
+	    
+	    static
+	    {
+	      for(Marker marker : values()) {
+	          markerMap.put(marker.getValue(), marker);
+	      }
+	    }	    
+   	  
+	    private final String description;
+	    private final short value;
+}
diff --git a/src/pixy/image/jpeg/QTable.java b/src/pixy/image/jpeg/QTable.java
new file mode 100644
index 0000000..41ca97e
--- /dev/null
+++ b/src/pixy/image/jpeg/QTable.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.jpeg;
+
+public class QTable implements Comparable<QTable> {
+	//
+	private int precision;
+	private int id;
+	private int[] data;
+	
+	public QTable(int precision, int id, int[] data) {
+		if(precision != 0 && precision != 1) 
+			throw new IllegalArgumentException("Invalid precision value: " + precision);
+		this.precision = precision;
+		this.id = id;
+		this.data = data;
+	}
+	
+	public int getPrecision() {
+		return precision; 
+	}
+	
+	public int getID() {
+		return id;
+	}
+	
+	public int[] getData() {
+		return data.clone();
+	}
+
+	public int compareTo(QTable that) {
+		return this.id - that.id;
+	}
+}
diff --git a/src/pixy/image/jpeg/SOFReader.java b/src/pixy/image/jpeg/SOFReader.java
new file mode 100644
index 0000000..fff5180
--- /dev/null
+++ b/src/pixy/image/jpeg/SOFReader.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.jpeg;
+
+import java.io.IOException;
+import java.util.EnumSet;
+
+import pixy.io.IOUtils;
+import pixy.util.Reader;
+
+/**
+ * JPEG SOF segment reader
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 10/09/2013
+ */
+public class SOFReader implements Reader {
+
+	private int precision;
+	private int frameHeight;
+	private int frameWidth;
+	private int numOfComponents;
+	private Component[] components;
+	private static final EnumSet<Marker> SOFS = 
+			EnumSet.of(Marker.SOF0, Marker.SOF1, Marker.SOF2, Marker.SOF3, Marker.SOF5, 
+		               Marker.SOF6, Marker.SOF7, Marker.SOF9, Marker.SOF10, Marker.SOF11,
+		               Marker.SOF13, Marker.SOF14, Marker.SOF15)
+	        ;
+	
+	private Segment segment;
+	
+	public SOFReader(Segment segment) throws IOException {
+		//
+		if(!SOFS.contains(segment.getMarker())) {
+			throw new IllegalArgumentException("Not a valid SOF segment: " + segment.getMarker());
+		}
+		
+		this.segment = segment;
+		read();
+	}
+	
+	public int getLength() {
+		return segment.getLength();
+	}
+	
+	public int getPrecision() {
+		return precision;
+	}
+	
+	public int getFrameHeight() {
+		return frameHeight;
+	}
+	
+	public int getFrameWidth() {
+		return frameWidth;
+	}
+	
+	public int getNumOfComponents() {
+		return numOfComponents;
+	}
+	
+	public Component[] getComponents() {
+		return components.clone();
+	}
+	
+	public void read() throws IOException {
+		//
+		byte[] data = segment.getData();
+		// This is in bits/sample, usually 8, (12 and 16 not supported by most software). 
+		precision = data[0]; // Usually 8, for baseline JPEG
+		// Image frame width and height
+		frameHeight = IOUtils.readUnsignedShortMM(data, 1);
+		frameWidth = IOUtils.readUnsignedShortMM(data, 3);
+		 // Number of components
+		// Usually 1 = grey scaled, 3 = color YCbCr or YIQ, 4 = color CMYK 
+        // JFIF uses either 1 component (Y, greyscaled) or 3 components (YCbCr, sometimes called YUV, color).
+		numOfComponents = data[5];
+		components = new Component[numOfComponents];
+	
+		int offset = 6;
+		
+		for (int i = 0; i < numOfComponents; i++) {
+			byte componentId = data[offset++];
+			// Sampling factors (1byte) (bit 0-3 horizontal, 4-7 vertical).
+			byte sampleFactor = data[offset++];
+			byte hSampleFactor = (byte)((sampleFactor>>4)&0x0f);
+			byte vSampleFactor = (byte)((sampleFactor&0x0f));
+			byte qTableNumber = data[offset++];
+					
+			components[i] = new Component(componentId, hSampleFactor, vSampleFactor, qTableNumber);
+		}
+	}
+}
diff --git a/src/pixy/image/jpeg/SOSReader.java b/src/pixy/image/jpeg/SOSReader.java
new file mode 100644
index 0000000..5f7ea91
--- /dev/null
+++ b/src/pixy/image/jpeg/SOSReader.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.jpeg;
+
+import java.io.IOException;
+
+import pixy.util.Reader;
+
+public class SOSReader implements Reader {
+	//
+	private Segment segment;
+	private SOFReader reader;
+	
+	int Ss, Se, Ah_Al, Ah, Al;
+	
+	public SOSReader(Segment segment) throws IOException {
+		//
+		if(segment.getMarker() != Marker.SOS) {
+			throw new IllegalArgumentException("Not a valid SOS segment!");
+		}
+		
+		this.segment = segment;
+		read();
+	}
+	
+	public SOSReader(Segment segment, SOFReader reader) throws IOException {
+		//
+		if(segment.getMarker() != Marker.SOS) {
+			throw new IllegalArgumentException("Not a valid SOS segment!");
+		}
+		
+		this.segment = segment;
+		this.reader = reader;
+		read();
+	}
+	
+	public void read() throws IOException {
+		//
+		byte[] data = segment.getData();		
+		int count = 0;
+		
+		byte numOfComponents = data[count++];
+		Component[] components = reader.getComponents();		
+		
+		for(int i = 0; i < numOfComponents; i++) {
+			byte id = data[count++];
+			byte tbl_no = data[count++];			
+		
+			for(Component component : components) {
+				if(component.getId() == id) {					
+					component.setACTableNumber((byte)(tbl_no&0x0f));
+					component.setDCTableNumber((byte)((tbl_no>>4)&0x0f));
+					break;
+				}
+			}
+		}
+		
+		//Start of spectral or predictor selection
+		Ss = data[count++];
+	    //End of spectral selection
+		Se = data[count++];
+		//Ah: Successive approximation bit position high
+		//Al: Successive approximation bit position low or point transform
+		Ah_Al = data[count++];
+	    Ah = (Ah_Al>>4)&0x0f;
+		Al = Ah_Al&0x0f;
+	}
+	
+	public void setSOFReader(SOFReader reader) {
+		this.reader = reader;
+	}
+}
diff --git a/src/pixy/image/jpeg/Segment.java b/src/pixy/image/jpeg/Segment.java
new file mode 100644
index 0000000..0485507
--- /dev/null
+++ b/src/pixy/image/jpeg/Segment.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * Segment.java
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    16Mar2015  Changed write() to work with stand-alone segments
+ */
+
+package pixy.image.jpeg;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import pixy.io.IOUtils;
+
+/**
+ * JPEG segment.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 05/21/2013
+ */
+public class Segment {
+
+	private Marker marker;
+	private int length;
+	private byte[] data;
+	
+	public Segment(Marker marker, int length, byte[] data) {
+		this.marker = marker;
+		this.length = length;
+		this.data = data;
+	}
+	
+	public Marker getMarker() {
+		return marker;
+	}
+	
+	public int getLength() {
+		return length;
+	}
+	
+	public byte[] getData() {
+		return data;
+	}
+	
+	public void write(OutputStream os) throws IOException {
+		IOUtils.writeShortMM(os, marker.getValue());
+		// If this is not a stand-alone segment, write the content as well
+		if(length > 0) {
+			IOUtils.writeShortMM(os, length);
+			IOUtils.write(os, data);
+		}
+	}
+	
+	@Override public String toString() {
+		return this.marker.toString();
+	}
+}
diff --git a/src/pixy/image/jpeg/SegmentBuilder.java b/src/pixy/image/jpeg/SegmentBuilder.java
new file mode 100644
index 0000000..11643e3
--- /dev/null
+++ b/src/pixy/image/jpeg/SegmentBuilder.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.jpeg;
+
+import pixy.util.Builder;
+
+/**
+ * Base builder for JPEG segments.
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 10/11/2013
+ */
+public abstract class SegmentBuilder implements Builder<Segment> {
+	//
+	private final Marker marker;
+	
+	public SegmentBuilder(Marker marker) {
+		this.marker = marker;
+	}
+		
+	public final Segment build() {
+		byte[] data = buildData();
+		
+		return new Segment(marker, data.length + 2, data);
+	}
+	
+	protected abstract byte[] buildData();
+}
diff --git a/src/pixy/image/jpeg/UnknownSegment.java b/src/pixy/image/jpeg/UnknownSegment.java
new file mode 100644
index 0000000..0ee0b4c
--- /dev/null
+++ b/src/pixy/image/jpeg/UnknownSegment.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.jpeg;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import pixy.io.IOUtils;
+
+/**
+ * Special segment to handle JPEG Marker.UNKNOWN.
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 05/22/2013
+ */
+public class UnknownSegment extends Segment {
+
+	private short markerValue;
+	
+	public UnknownSegment(short markerValue, int length, byte[] data) {
+		super(Marker.UNKNOWN, length, data);
+		this.markerValue = markerValue;
+	}
+	
+	public short getMarkerValue() {
+		return markerValue;
+	}
+	
+	@Override public void write(OutputStream os) throws IOException{
+		IOUtils.writeIntMM(os, getLength());
+		IOUtils.writeIntMM(os, this.markerValue);
+		IOUtils.write(os, getData());
+	}
+	
+	@Override public String toString() {
+		return super.toString() + "[Marker value: 0x"+ Integer.toHexString(markerValue&0xffff)+"]";
+	}
+}
diff --git a/src/pixy/image/png/Chunk.java b/src/pixy/image/png/Chunk.java
new file mode 100644
index 0000000..2ca8729
--- /dev/null
+++ b/src/pixy/image/png/Chunk.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import pixy.util.zip.CRC32;
+import pixy.io.IOUtils;
+import pixy.util.ArrayUtils;
+import pixy.util.LangUtils;
+
+/**
+ * Class for PNG chunks
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 11/07/2012
+ */
+public class Chunk implements Comparable<Chunk> {
+
+	private final long length;
+	private final ChunkType chunkType;
+	private final byte[] data;
+	private final long crc;	
+	
+	/**
+	 * Compare different chunks according to their Attribute ranking.
+	 *  
+	 * This is intended to be used for comparing chunks with different
+	 * chunk types rather than chunks of the same chunkType which will always 
+	 * have the same ranking.
+	 */
+	public int compareTo(Chunk that) {
+    	return this.chunkType.getRanking() - that.chunkType.getRanking();
+    }
+	
+	public Chunk(ChunkType chunkType, long length, byte[] data, long crc) {
+		this.length = length;
+		this.chunkType = chunkType;
+		this.data = data;
+		this.crc = crc;
+	}
+	
+	public ChunkType getChunkType() {
+		return chunkType;
+	}	
+	
+	public long getLength() {
+		return this.length;
+	}
+	
+	public byte[] getData() {
+		return this.data.clone();
+	}	
+	
+	public long getCRC() {
+		return this.crc;
+	}
+	
+	public boolean isValidCRC() {
+		return (calculateCRC(chunkType.getValue(), data) == crc);
+	}
+	
+	public void write(OutputStream os) throws IOException {
+		IOUtils.writeIntMM(os, (int)length);
+		IOUtils.writeIntMM(os, chunkType.getValue());
+		IOUtils.write(os, data);
+		IOUtils.writeIntMM(os, (int)crc);		
+	}
+	
+	@Override public String toString() {
+		return this.chunkType.toString();
+	}
+	
+	public boolean equals(Object that) {
+		
+		if (! (that instanceof Chunk)) {
+			return false;
+		}
+		
+		Chunk other = (Chunk)that;
+		
+		long thisCRC = calculateCRC(this.getChunkType().getValue(), this.getData());
+		long otherCRC = calculateCRC(other.getChunkType().getValue(), other.getData());
+
+		return thisCRC == otherCRC;
+	}
+	
+	public int hashCode() {
+		return LangUtils.longToIntHashCode(calculateCRC(this.getChunkType().getValue(), this.getData()));
+	}
+	
+	public static long calculateCRC(int chunkValue, byte[] data)
+	{
+		CRC32 crc32 = new CRC32();
+		 
+		crc32.update(ArrayUtils.toByteArrayMM(chunkValue));
+		crc32.update(data);
+		 
+		return crc32.getValue();
+	}
+	
+	public static long calculateCRC(int chunkValue, byte[] data, int offset, int length)
+	{
+		CRC32 crc32 = new CRC32();
+		 
+		crc32.update(ArrayUtils.toByteArrayMM(chunkValue));
+		crc32.update(data, offset, length);
+		 
+		return crc32.getValue();
+	}
+}
diff --git a/src/pixy/image/png/ChunkBuilder.java b/src/pixy/image/png/ChunkBuilder.java
new file mode 100644
index 0000000..917b26e
--- /dev/null
+++ b/src/pixy/image/png/ChunkBuilder.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import pixy.util.Builder;
+
+/**
+ * Base builder for PNG chunks.
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 12/30/2012
+ */
+public abstract class ChunkBuilder implements Builder<Chunk> {
+
+	private final ChunkType chunkType;
+	
+	public ChunkBuilder(ChunkType chunkType) {
+		this.chunkType = chunkType;
+	}
+	
+	protected ChunkType getChunkType() {
+		return chunkType;
+	}
+	
+	public final Chunk build() {
+		byte[] data = buildData();
+		
+		long crc = Chunk.calculateCRC(chunkType.getValue(), data);
+	    
+		return new Chunk(chunkType, data.length, data, crc);
+	}
+	
+	protected abstract byte[] buildData();
+}
diff --git a/src/pixy/image/png/ChunkType.java b/src/pixy/image/png/ChunkType.java
new file mode 100644
index 0000000..6453bd9
--- /dev/null
+++ b/src/pixy/image/png/ChunkType.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Define PNG chunk types
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com 
+ * @version 1.0 10/16/2012
+ */
+public enum ChunkType {	
+	// Four critical chunks
+	IHDR("IHDR", 0x49484452, Attribute.CRITICAL, 1), // PNG header, must be the first one
+	IDAT("IDAT", 0x49444154, Attribute.CRITICAL, 60), // PNG data, could have multiple but must appear consecutively
+	IEND("IEND", 0x49454E44, Attribute.CRITICAL, 100), // End of image, must be the last one
+	PLTE("PLTE", 0x504C5445, Attribute.CRITICAL, 40), // ColorPalette, must precede the first IDAT
+	// Fourteen ancillary chunks	
+	TEXT("tEXt", 0x74455874, Attribute.ANCILLARY, 20), // Anywhere between IHDR and IEND
+	ZTXT("zTXt", 0x7A545874, Attribute.ANCILLARY, 20), // Anywhere between IHDR and IEND
+	ITXT("iTXt", 0x69545874, Attribute.ANCILLARY, 20), // Anywhere between IHDR and IEND
+	TRNS("tRNS", 0x74524E53, Attribute.ANCILLARY, 50), // Must precede the first IDAT chunk and must follow the PLTE chunk
+	GAMA("gAMA", 0x67414D41, Attribute.ANCILLARY, 30), // Must precede the first IDAT chunk and the PLTE chunk if present
+	CHRM("cHRM", 0x6348524D, Attribute.ANCILLARY, 30), // Must precede the first IDAT chunk and the PLTE chunk if present
+	SRGB("sRGB", 0x73524742, Attribute.ANCILLARY, 30), // Must precede the first IDAT chunk and the PLTE chunk if present
+	ICCP("iCCP", 0x69434350, Attribute.ANCILLARY, 30), // Must precede the first IDAT chunk and the PLTE chunk if present
+	BKGD("bKGD", 0x624B4744, Attribute.ANCILLARY, 50), // Must precede the first IDAT chunk and must follow the PLTE chunk
+	PHYS("pHYs", 0x70485973, Attribute.ANCILLARY, 30), // Must precede the first IDAT chunk
+	SBIT("sBIT", 0x73424954, Attribute.ANCILLARY, 30), // Must precede the first IDAT chunk and the PLTE chunk if present
+	SPLT("sPLT", 0x73504C54, Attribute.ANCILLARY, 30), // Must precede the first IDAT chunk
+	HIST("hIST", 0x68495354, Attribute.ANCILLARY, 50), // Must precede the first IDAT chunk and must follow the PLTE chunk
+	TIME("tIME", 0x74494D45, Attribute.ANCILLARY, 20), // Anywhere between IHDR and IEND
+    
+	UNKNOWN("UNKNOWN",  0x00000000, Attribute.ANCILLARY, 99); // We don't know this chunk, ranking it right before IEND
+	
+	/**
+	 * We made Attribute public for general usage outside of Attribute class.
+	 *
+	 * Nested enum types are implicitly static. 
+	 */
+    public enum Attribute {
+    	CRITICAL {
+    		   public String[] getNames() { 
+    			   // Use clone() to prevent users from changing the values of the internal array elements 
+    			   return CRITICAL_NAMES.clone(); 
+    		   }
+    		   
+    		   public int[] getValues() {
+    			   return CRITICAL_VALUES.clone();
+    		   }
+    	},
+    	ANCILLARY {
+    		   public String[] getNames() { 
+ 			       return ANCILLARY_NAMES.clone();
+ 		       }
+ 		   
+ 		       public int[] getValues() {
+ 			       return ANCILLARY_VALUES.clone();
+ 		       }
+    	};
+    	
+    	public abstract String[] getNames();
+    	public abstract int[] getValues();
+    	
+    	private static final String[] CRITICAL_NAMES = {"IHDR","IDAT","IEND","PLTE"}; 
+    	private static final String[] ANCILLARY_NAMES = {
+    		                             "tEXt","zTXt","iTXt","tRNS","gAMA","cHRM","sRGB",
+    		                             "iCCP","bKGD","pHYs","sBIT","sPLT","hIST","tIME"
+    		                            };
+    	private static final int[] CRITICAL_VALUES = {0x49484452,0x49444154,0x49454E44,0x504C5445};
+    	private static final int[] ANCILLARY_VALUES = {
+    		                             0x74455874,0x7A545874,0x69545874,0x74524E53,0x67414D41,0x6348524D,0x73524742,
+    		                             0x69434350,0x624B4744,0x70485973,0x73424954,0x73504C54,0x68495354,0x74494D45
+    		                            };
+    } // End of Attribute definition
+    
+    private ChunkType(String name, int value, Attribute attribute, int ranking)
+    {
+    	this.name = name;
+    	this.value = value;
+        this.attribute = attribute;	
+        this.ranking = ranking;
+    }    
+    
+    public Attribute getAttribute()
+    {
+    	return this.attribute;
+    }
+    
+    public String getName()
+    {
+    	return this.name;
+    }
+    
+    public int getValue()
+    {
+    	return this.value;
+    }
+    /**
+     * Ranking is used for sorting chunks to make them conform to PNG specification 
+     * before passing them to PNGWriter. 
+     *  
+     * @return The ranking of the chunk for this chunk type
+     */
+    public int getRanking() {
+    	return this.ranking;
+    }   
+    
+    @Override
+    public String toString() {return name;}
+    
+    /**
+     * @param name  A String to test against the names of the chunks
+     * @return  True if a match is found, otherwise false. 
+     */
+    public static boolean containsIgnoreCase(String name) 
+    {
+    	return stringMap.containsKey(name.toUpperCase());
+    }
+   
+    public static ChunkType fromString(String name)
+    {
+        return stringMap.get(name.toUpperCase());
+    }
+    
+    public static ChunkType fromInt(int value) {
+       	ChunkType chunkType = intMap.get(value);
+    	if (chunkType == null)
+    	   return UNKNOWN;
+    	return chunkType;
+    }
+    
+    private static final Map<String, ChunkType> stringMap = new HashMap<String, ChunkType>();
+    private static final Map<Integer, ChunkType> intMap = new HashMap<Integer, ChunkType>();
+    
+    static
+    {
+      for(ChunkType chunk : values()) {
+          stringMap.put(chunk.toString().toUpperCase(), chunk);
+          intMap.put(chunk.getValue(), chunk);
+      }
+    }   
+    
+    private final Attribute attribute;
+    private final String name;
+    private final int value;
+    private final int ranking;
+}
diff --git a/src/pixy/image/png/ColorType.java b/src/pixy/image/png/ColorType.java
new file mode 100644
index 0000000..71a3274
--- /dev/null
+++ b/src/pixy/image/png/ColorType.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Define PNG image color types
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com 
+ * @version 1.0 07/29/2013
+ */
+public enum ColorType {	
+	// Image color formats
+	GRAY_SCALE(0, "Gray-scale: each pixel is a grayscale sample."),
+	TRUE_COLOR(2, "True-color: each pixel is a R,G,B triple."),
+	INDEX_COLOR(3, "Index-color: each pixel is a palette index; a PLTE chunk must appear."), 
+	GRAY_SCALE_WITH_ALPHA(4, "Gray-scale-with-alpha: each pixel is a grayscale sample, followed by an alpha sample."), 
+	TRUE_COLOR_WITH_ALPHA(6, "True-color-with-alpha: each pixel is a R,G,B triple, followed by an alpha sample."), 
+    
+	UNKNOWN(999, "UNKNOWN"); // We don't know this color format
+	
+	private ColorType(int value, String description)
+    {
+    	this.value = value;
+        this.description = description;	
+    }    
+    
+    public String getDescription()
+    {
+    	return this.description;
+    }
+    
+    public int getValue()
+    {
+    	return this.value;
+    }
+      
+    @Override
+    public String toString() {return "Image color format: " + getValue() + " - " + description;}
+    
+    public static ColorType fromInt(int value) {
+       	ColorType colorType = intMap.get(value);
+    	if (colorType == null)
+    	   return UNKNOWN;
+   		return colorType;
+    }
+    
+    private static final Map<Integer, ColorType> intMap = new HashMap<Integer, ColorType>();
+    
+    static
+    {
+      for(ColorType color : values()) {
+          intMap.put(color.getValue(), color);
+      }
+    }   
+    
+    private final String description;
+    private final int value;
+}
diff --git a/src/pixy/image/png/Filter.java b/src/pixy/image/png/Filter.java
new file mode 100644
index 0000000..b97d3ba
--- /dev/null
+++ b/src/pixy/image/png/Filter.java
@@ -0,0 +1,242 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+/**
+ * PNG scan line filter
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 04/29/2013
+ */
+public class Filter {
+	
+	public static final int NONE = 0;
+	public static final int SUB = 1;
+	public static final int UP = 2;
+	public static final int AVERAGE = 3;
+	public static final int PAETH = 4;
+	
+	public static void defilter_average(int bytesPerPixel, int bytesPerScanLine, byte[] sample, int offset)
+	{		
+		int previous = 0;
+		int upper = 0;
+		
+		int end = offset + bytesPerScanLine;
+		int subStart = offset + bytesPerPixel;
+	
+		// First scan line, only previous bytes of the same line is used
+		if(offset < bytesPerScanLine) 
+		{
+			for (int i = subStart; i < end; i++)
+			{
+				previous = (sample[i-bytesPerPixel]&0xff);
+				sample[i] = (byte)((sample[i]&0xff) + (previous>>1));
+			}
+	
+			return;			
+		}
+		
+		// Only upper line bytes are used
+		for (int i = offset; i < subStart; i++) 
+		{
+			upper = (sample[i-bytesPerScanLine]&0xff);			
+			sample[i] = (byte)((sample[i]&0xff) + (upper>>1));
+		}
+		
+		// Both upper line and previous bytes of the same line are used.
+		for (int i = subStart; i < end; i++) 
+		{
+			upper = (sample[i-bytesPerScanLine]&0xff);			
+			previous = (sample[i-bytesPerPixel]&0xff);
+			sample[i] = (byte)((sample[i]&0xff) + ((upper + previous)>>1));
+		}
+	}
+	
+	public static void defilter_paeth(int bytesPerPixel, int bytesPerScanLine, byte[] sample, int offset)
+	{
+		int previous = 0;
+		int upper = 0;
+		int upper_previous = 0;
+		
+		int end = offset + bytesPerScanLine;
+		int subStart = offset + bytesPerPixel;
+		
+		// First scan line, only previous bytes of the same line is used
+		if(offset < bytesPerScanLine) 
+		{
+			for (int i = subStart; i < end; i++)
+			{
+				previous = (sample[i-bytesPerPixel]&0xff);
+				sample[i] = (byte)((sample[i]&0xff) + previous);
+			}
+	
+			return;			
+		}
+		
+		// Only upper line bytes are used
+		for (int i = offset; i < subStart; i++) 
+		{
+			upper = (sample[i-bytesPerScanLine]&0xff);			
+			sample[i] = (byte)((sample[i]&0xff) + upper);
+		}
+		
+		for (int i = subStart; i < end; i++)
+		{
+			upper = (sample[i-bytesPerScanLine]&0xff);
+			previous = (sample[i-bytesPerPixel]&0xff);
+	        upper_previous = (sample[i-bytesPerScanLine-bytesPerPixel]&0xff);
+	        sample[i] = (byte)((sample[i]&0xff) + paeth_predictor(previous,upper,upper_previous));			 
+		}
+	}
+	
+	public static void defilter_sub(int bytesPerPixel, int bytesPerScanLine, byte[] sample, int offset)
+	{
+		int end = offset + bytesPerScanLine;
+	
+		for (int i = offset + bytesPerPixel; i < end; i++)
+		{
+			sample[i] = (byte)((sample[i]&0xff) + (sample[i-bytesPerPixel]&0xff));
+		}
+	}
+	
+	public static void defilter_up(int bytesPerScanLine, byte[] sample, int offset)
+	{
+		if (offset < bytesPerScanLine) { // up is meaningless for the first row
+			return;
+		}
+		
+		int end = offset + bytesPerScanLine;
+	
+		for (int i = offset; i < end; i++)
+		{
+			sample[i] = (byte)((sample[i]&0xff) + (sample[i-bytesPerScanLine]&0xff));
+		}
+	}
+
+	public static void filter_average(int bytesPerPixel, int bytesPerScanLine, byte[] sample, int offset)
+	{	
+		int previous = 0;
+		int upper = 0;
+		
+		int end = offset + bytesPerScanLine;
+		int subStart = offset + bytesPerPixel;
+	
+		// First scan line, only previous bytes of the same line is used
+		if(offset < bytesPerScanLine) 
+		{
+			for (int i = end - 1; i >= subStart; i--)
+			{
+				previous = (sample[i-bytesPerPixel]&0xff);
+				sample[i] = (byte)((sample[i]&0xff) - (previous>>1));
+			}
+	
+			return;			
+		}
+		
+		// Both upper line and previous bytes of the same line are used.
+		for (int i = end - 1; i >= subStart; i--) 
+		{
+			upper = (sample[i-bytesPerScanLine]&0xff);			
+			previous = (sample[i-bytesPerPixel]&0xff);
+			sample[i] = (byte)((sample[i]&0xff) - ((upper + previous)>>1));
+		}
+		
+		// Only upper line bytes are used
+		for (int i = subStart - 1; i >= offset; i--) 
+		{
+			upper = (sample[i-bytesPerScanLine]&0xff);			
+			sample[i] = (byte)((sample[i]&0xff) - (upper>>1));
+		}	
+	}
+	
+	public static void filter_paeth(int bytesPerPixel, int bytesPerScanLine, byte[] sample, int offset)
+	{
+		int previous = 0;
+		int upper = 0;
+		int upper_left = 0;
+			
+		int subStart = offset + bytesPerPixel;
+		int end = offset + bytesPerScanLine;		
+	
+		if (offset < bytesPerScanLine) { // First line
+			for (int i = end - 1; i >= subStart; i--)
+			{
+				previous = (sample[i - bytesPerPixel]&0xff);
+				sample[i] = (byte)((sample[i]&0xff) - previous);
+			}
+			
+			return;
+		}
+		
+		// Use previous, upper and upper_left bytes
+		for (int i = end - 1; i >= subStart; i--)
+		{
+			upper = (sample[i-bytesPerScanLine]&0xff);
+			previous = (sample[i-bytesPerPixel]&0xff);
+	        upper_left = (sample[i-bytesPerScanLine-bytesPerPixel]&0xff);
+	        sample[i] = (byte)((sample[i]&0xff) - paeth_predictor(previous, upper, upper_left));			 
+		}
+		
+		// Only upper line bytes are used
+		for (int i = subStart - 1; i >= offset; i--) 
+		{
+			upper = (sample[i-bytesPerScanLine]&0xff);			
+			sample[i] = (byte)((sample[i]&0xff) -upper);
+		}
+	}
+	
+	public static void filter_sub(int bytesPerPixel, int bytesPerScanLine, byte[] sample, int offset)
+	{
+		int start = offset + bytesPerPixel;
+		int end = offset + bytesPerScanLine;
+	
+		for (int i = end - 1; i >= start; i--)
+		{
+			sample[i] = (byte)((sample[i]&0xff) - (sample[i-bytesPerPixel]&0xff));
+		}
+	}
+	
+	public static void filter_up(int bytesPerScanLine, byte[] sample, int offset)
+	{
+		if (offset < bytesPerScanLine) { // up is meaningless for the first row
+			return;
+		}
+		
+		int start  = offset - bytesPerScanLine;
+		int end = offset + bytesPerScanLine;
+	
+		for (int i = offset; i < end; i++)
+		{		
+			sample[i] = (byte)((sample[i]&0xff) - (sample[start++]&0xff));
+		}
+	}
+	
+	private static int paeth_predictor(int left, int above, int upper_left)
+	{
+		int p = left+above-upper_left;
+		int p_left = (p>left)?(p - left):(left-p);
+		int p_above = (p>above)?(p - above):(above-p);
+		int p_upper_left = (p>upper_left)?(p - upper_left):(upper_left-p);
+		
+		if ((p_left<=p_above)&&(p_left<=p_upper_left))
+			 return left;
+		else if (p_above<=p_upper_left)
+			 return above;
+		else return upper_left;
+	}
+	
+	private Filter() { }
+}
diff --git a/src/pixy/image/png/ICCPBuilder.java b/src/pixy/image/png/ICCPBuilder.java
new file mode 100644
index 0000000..99ad2c1
--- /dev/null
+++ b/src/pixy/image/png/ICCPBuilder.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.io.BufferedOutputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.zip.DeflaterOutputStream;
+
+import pixy.util.Builder;
+
+/**
+ * PNG iCCP chunk builder
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 12/23/2014
+ */
+public class ICCPBuilder extends ChunkBuilder implements Builder<Chunk> {
+	
+	private String profileName;
+	private byte[] profileData;
+
+	public ICCPBuilder() {
+		super(ChunkType.ICCP);
+	}
+	
+	public ICCPBuilder data(byte[] data) {
+		this.profileData = data;		
+		return this;
+	}
+	
+	public ICCPBuilder name(String name) {
+		this.profileName = name.trim().replaceAll("\\s+", " ");
+		return this;		
+	}
+
+	@Override
+	protected byte[] buildData() {
+		StringBuilder sb = new StringBuilder(this.profileName);
+		sb.append('\0'); // Null separator
+		sb.append('\0'); // Compression method	
+		ByteArrayOutputStream bo = new ByteArrayOutputStream(1024);	
+		try {
+			bo.write(sb.toString().getBytes("iso-8859-1"));		
+			DeflaterOutputStream ds = new DeflaterOutputStream(bo);
+			BufferedOutputStream bout = new BufferedOutputStream(ds);
+			bout.write(profileData);
+			bout.flush();
+			bout.close();
+		} catch(Exception ex) { 
+			ex.printStackTrace();
+		}
+		
+		return bo.toByteArray();				
+	}
+}
diff --git a/src/pixy/image/png/IDATBuilder.java b/src/pixy/image/png/IDATBuilder.java
new file mode 100644
index 0000000..92c480c
--- /dev/null
+++ b/src/pixy/image/png/IDATBuilder.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.io.ByteArrayOutputStream;
+import java.util.zip.Deflater;
+
+import pixy.util.Builder;
+
+/**
+ * PNG IDAT chunk builder
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 04/26/2013
+ */
+public class IDATBuilder extends ChunkBuilder implements Builder<Chunk> {
+
+	private ByteArrayOutputStream bout = new ByteArrayOutputStream(4096);
+	private Deflater deflater = new Deflater(5);
+		
+	public IDATBuilder() {
+		super(ChunkType.IDAT);		
+	}
+	
+	public IDATBuilder(int compressionLevel) {
+		this();
+		deflater = new Deflater(compressionLevel);
+	}
+	
+	public IDATBuilder data(byte[] data, int offset, int length) {
+		// Caches the bytes
+		bout.write(data, offset, length);
+		
+		return this;
+	}
+	
+	public IDATBuilder data(byte[] data) {
+		return data(data, 0, data.length);
+	}
+
+	@Override
+	protected byte[] buildData() {
+		// Compresses raw data
+		deflater.setInput(bout.toByteArray());
+		
+		bout.reset();
+		byte buffer[] = new byte[4096];
+		
+		if(finish)
+			// This is to make sure we get all the input data compressed
+			deflater.finish();
+		
+		while(!deflater.finished()) {
+			int bytesCompressed = deflater.deflate(buffer);
+			if(bytesCompressed <= 0) break;
+			bout.write(buffer, 0, bytesCompressed);
+		}		 
+		
+		byte temp[] = bout.toByteArray();
+			
+		bout.reset();
+		
+		return temp;
+	}
+	
+	public void setFinish(boolean finish) {
+		this.finish = finish;
+	}
+	
+	private boolean finish;
+}
diff --git a/src/pixy/image/png/IDATReader.java b/src/pixy/image/png/IDATReader.java
new file mode 100644
index 0000000..1c52ed4
--- /dev/null
+++ b/src/pixy/image/png/IDATReader.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.zip.InflaterInputStream;
+
+import pixy.io.IOUtils;
+import pixy.util.Reader;
+
+/**
+ * PNG IDAT chunk reader
+ * <p>
+ * All the IDAT chunks must be merged together before using this reader, as
+ * per PNG specification, the compressed data stream is the concatenation of
+ * the contents of all the IDAT chunks.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 04/26/2013
+ */
+public class IDATReader implements Reader {
+
+	private byte[] rawData;
+	private ByteArrayOutputStream byteOutput = null;
+	
+	public IDATReader() {
+		this(8192); // 8K buffer
+	}
+	
+	public IDATReader(int bufLen) {
+		byteOutput = new ByteArrayOutputStream(bufLen);
+	}
+	
+	public IDATReader addChunk(Chunk chunk) {
+		if(chunk == null) throw new IllegalArgumentException("Input chunk is null");
+
+		if (chunk.getChunkType() != ChunkType.IDAT) {
+			throw new IllegalArgumentException("Not a valid IDAT chunk.");
+		}		
+		
+		try {
+			byteOutput.write(chunk.getData());
+		} catch (IOException e) {
+			throw new RuntimeException("IDATReader: error adding new chunk");
+		}
+		
+		return this;
+	}
+	
+	public byte[] getData() throws IOException {
+		if(rawData == null)
+			read();
+		return rawData;
+	}
+
+	public void read() throws IOException {		
+		// Inflate compressed data
+		BufferedInputStream bin = new BufferedInputStream(new InflaterInputStream(new ByteArrayInputStream(byteOutput.toByteArray())));
+		this.rawData = IOUtils.inputStreamToByteArray(bin);
+		bin.close();
+	}
+}
diff --git a/src/pixy/image/png/IENDBuilder.java b/src/pixy/image/png/IENDBuilder.java
new file mode 100644
index 0000000..7e481d1
--- /dev/null
+++ b/src/pixy/image/png/IENDBuilder.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import pixy.util.Builder;
+
+/**
+ * PNG IEND chunk builder
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 04/29/2013
+ */
+public class IENDBuilder extends ChunkBuilder implements Builder<Chunk> {
+
+	public IENDBuilder() {
+		super(ChunkType.IEND);	
+	}
+
+	@Override
+	protected byte[] buildData() {
+		return new byte[0];
+	}
+}
diff --git a/src/pixy/image/png/IENDReader.java b/src/pixy/image/png/IENDReader.java
new file mode 100644
index 0000000..b2109f6
--- /dev/null
+++ b/src/pixy/image/png/IENDReader.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.io.IOException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.util.Reader;
+
+/**
+ * PNG IEND chunk reader
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 04/29/2013
+ */
+public class IENDReader implements Reader {
+
+	private Chunk chunk;
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(IENDReader.class);
+	
+	public IENDReader(Chunk chunk) {
+		if(chunk == null) throw new IllegalArgumentException("Input chunk is null");
+		
+		if (chunk.getChunkType() != ChunkType.IEND) {
+			throw new IllegalArgumentException("Not a valid IEND chunk.");
+		}
+		
+		this.chunk = chunk;
+		
+		try {
+			read();
+		} catch (IOException e) {
+			throw new RuntimeException("IENDReader: error reading chunk");
+		}
+	}
+
+	public void read() throws IOException {
+		if(chunk.getData().length != 0) {
+			LOGGER.warn("Warning: IEND data field is not empty!");
+		}
+	}
+}
diff --git a/src/pixy/image/png/IHDRBuilder.java b/src/pixy/image/png/IHDRBuilder.java
new file mode 100644
index 0000000..63cc9b6
--- /dev/null
+++ b/src/pixy/image/png/IHDRBuilder.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import pixy.util.Builder;
+
+/**
+ * PNG IHDR chunk builder
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 04/25/2013
+ */
+public class IHDRBuilder extends ChunkBuilder implements Builder<Chunk> {
+	/*
+	 * Color   Allowed         Interpretation
+   	 * Type    Bit Depths
+   	 *
+   	 *	0      1, 2, 4, 8, 16  Each pixel is a grayscale sample.
+     *
+   	 *	2      8, 16           Each pixel is an R,G,B triple.
+     *
+   	 *	3      1, 2, 4, 8      Each pixel is a palette index;
+     *                         a PLTE chunk must appear.
+     *
+   	 *	4      8, 16           Each pixel is a grayscale sample,
+     *                         followed by an alpha sample.
+     *
+   	 *	6      8, 16           Each pixel is an R,G,B triple,
+     *                         followed by an alpha sample.
+	 */
+	private int width = 0;
+	private int height = 0;
+	private int bitDepth = 0;
+	private int colorType = 0;
+	private int compressionMethod = 0;
+	private int filterMethod = 0;
+	private int interlaceMethod = 0;
+
+	public IHDRBuilder width(int width) {
+		if (width <= 0) throw new IllegalArgumentException("Invalid width: " + width);
+		this.width = width;
+		return this;
+	}
+	
+	public IHDRBuilder height(int height) {
+		if (height <= 0) throw new IllegalArgumentException("Invalid height: " + height);
+		this.height = height;
+		return this;
+	}
+	
+	public IHDRBuilder bitDepth(int bitDepth) {
+		switch(bitDepth) {
+			case 1:
+			case 2:
+			case 4:
+			case 8:
+			case 16:
+				this.bitDepth = bitDepth;
+				return this;
+			default:
+				throw new IllegalArgumentException("Invalid bitDepth: " + bitDepth);
+		}		
+	}
+	
+	public IHDRBuilder colorType(ColorType colorType) {
+		switch(colorType) {
+			case GRAY_SCALE:
+			case TRUE_COLOR:
+			case INDEX_COLOR:
+			case GRAY_SCALE_WITH_ALPHA:
+			case TRUE_COLOR_WITH_ALPHA:
+				this.colorType = colorType.getValue();
+				return this;
+			default:
+				throw new IllegalArgumentException("Invalid colorType: " + colorType);
+		}		
+	}
+	
+	// Only compression method 0 => deflate/inflate is allowed.
+	public IHDRBuilder compressionMethod(int compressionMethod) {
+		if (compressionMethod != 0) throw new IllegalArgumentException("Invalid comressionMethod" + compressionMethod);
+		this.compressionMethod = compressionMethod;
+		return this;
+	}
+	
+	// Only filter method 0 (adaptive filtering with five basic filter types) is defined.
+	public IHDRBuilder filterMethod(int filterMethod) {
+		if(filterMethod != 0) throw new IllegalArgumentException("Invalid filterMethod: " + filterMethod);
+		this.filterMethod = filterMethod;
+		return this;
+	}
+	
+	// 0 => no interlace; 1 => Adam7 interlace
+	public IHDRBuilder interlaceMethod(int interlaceMethod) {
+		if ((interlaceMethod != 0) && (interlaceMethod != 1) )throw new IllegalArgumentException("Invalid interlaceMethod" + interlaceMethod);
+		this.interlaceMethod = interlaceMethod;
+		return this;
+	}
+	
+	public IHDRBuilder() {
+		super(ChunkType.IHDR);		
+	}	
+
+	@Override
+	protected byte[] buildData() {
+		// 13 bytes
+		byte[] data = {(byte)(width >>> 24),
+		         (byte)(width >>> 16), (byte)(width >>> 8),
+		         (byte)width, (byte)(height >>> 24),
+		         (byte)(height >>> 16), (byte)(height >>> 8),
+		         (byte)height, (byte)bitDepth, (byte)colorType, (byte)compressionMethod,
+		         (byte)filterMethod, (byte)interlaceMethod };
+		
+		return data;
+	}
+}
diff --git a/src/pixy/image/png/IHDRReader.java b/src/pixy/image/png/IHDRReader.java
new file mode 100644
index 0000000..4dea465
--- /dev/null
+++ b/src/pixy/image/png/IHDRReader.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.io.IOException;
+
+import pixy.io.IOUtils;
+import pixy.util.Reader;
+
+/**
+ * PNG IHDR chunk reader
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 04/25/2013
+ */
+public class IHDRReader implements Reader {
+
+	private int width = 0;
+	private int height = 0;
+	private byte bitDepth = 0;
+	private byte colorType = 0;
+	private byte compressionMethod = 0;
+	private byte filterMethod = 0;
+	private byte interlaceMethod = 0;
+	private Chunk chunk;
+	
+	public IHDRReader(Chunk chunk) {
+		if(chunk == null) throw new IllegalArgumentException("Input chunk is null");
+		
+		if (chunk.getChunkType() != ChunkType.IHDR) {
+			throw new IllegalArgumentException("Not a valid IHDR chunk.");
+		}
+		
+		this.chunk = chunk;
+		
+		try {
+			read();
+		} catch (IOException e) {
+			throw new RuntimeException("IHDRReader: error reading chunk");
+		}
+	}
+	
+	public int getWidth() { return width; }
+	public int getHeight() { return height; }
+	public byte getBitDepth() { return bitDepth; }
+	public byte getColorType() { return colorType; }
+	public byte getCompressionMethod() { return compressionMethod; }
+	public byte getFilterMethod() { return filterMethod; }
+	public byte getInterlaceMethod() { return interlaceMethod; }
+
+	public void read() throws IOException {	
+		//
+		byte[] data = chunk.getData();
+		
+		this.width = IOUtils.readIntMM(data, 0);
+		this.height = IOUtils.readIntMM(data, 4);
+		this.bitDepth = data[8];
+		this.colorType = data[9];
+		this.compressionMethod = data[10];
+		this.filterMethod = data[11];
+		this.interlaceMethod = data[12];
+	}
+}
diff --git a/src/pixy/image/png/PLTEBuilder.java b/src/pixy/image/png/PLTEBuilder.java
new file mode 100644
index 0000000..c2412ce
--- /dev/null
+++ b/src/pixy/image/png/PLTEBuilder.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import pixy.util.Builder;
+
+/**
+ * PNG PLTE chunk builder
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 04/26/2013
+ */
+public class PLTEBuilder extends ChunkBuilder implements Builder<Chunk> {
+
+	private byte[] redMap;
+	private byte[] greenMap;
+	private byte[] blueMap;
+	
+	public PLTEBuilder() {
+		super(ChunkType.PLTE);		
+	}
+
+	public PLTEBuilder redMap(byte[] redMap) {
+		this.redMap = redMap;		
+		return this;
+	}
+	
+	public PLTEBuilder greenMap(byte[] greenMap) {
+		this.greenMap = greenMap;
+		return this;
+	}
+	
+	public PLTEBuilder blueMap(byte[] blueMap) {
+		this.blueMap = blueMap;
+		return this;
+	}
+	
+	@Override
+	protected byte[] buildData() {
+		// Converts to PNG RGB PLET format
+		int mapLen = redMap.length;
+		byte[] colorMap = new byte[3*mapLen];
+		
+		for (int i = mapLen - 1, j = colorMap.length - 1; i >= 0; i--) {			
+			colorMap[j--] = blueMap[i];
+			colorMap[j--] = greenMap[i];
+			colorMap[j--] = redMap[i];
+		}
+
+		return colorMap;
+	}
+}
diff --git a/src/pixy/image/png/PLTEReader.java b/src/pixy/image/png/PLTEReader.java
new file mode 100644
index 0000000..aeb531b
--- /dev/null
+++ b/src/pixy/image/png/PLTEReader.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.io.IOException;
+
+import pixy.util.Reader;
+
+/**
+ * PNG PLTE chunk reader
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 04/26/2013
+ */
+public class PLTEReader implements Reader {
+
+	private byte[] redMap;
+	private byte[] greenMap;
+	private byte[] blueMap;
+	private Chunk chunk;
+	
+	public PLTEReader(Chunk chunk) {
+		if(chunk == null) throw new IllegalArgumentException("Input chunk is null");
+		
+		if (chunk.getChunkType() != ChunkType.PLTE) {
+			throw new IllegalArgumentException("Not a valid PLTE chunk.");
+		}
+		
+		this.chunk = chunk;
+		
+		try {
+			read();
+		} catch (IOException e) {
+			throw new RuntimeException("PLTEReader: error reading chunk");
+		}
+	}
+	
+	public byte[] getRedMap() { return redMap; }
+	public byte[] getGreenMap() { return greenMap; }
+	public byte[] getBlueMap() { return blueMap; }
+	
+	public void read() throws IOException {	
+		
+		byte[] colorMap = chunk.getData();
+		int mapLen = colorMap.length;
+		
+		if ((mapLen % 3) != 0) {
+			throw new IllegalArgumentException("Invalid colorMap length: " + mapLen);
+		}
+		
+		redMap = new byte[mapLen/3];
+		greenMap = new byte[mapLen/3];
+		blueMap = new byte[mapLen/3];
+		
+		for (int i = mapLen - 1, j = redMap.length - 1; j >= 0; j--) {
+			blueMap[j]  = colorMap[i--];
+			greenMap[j] = colorMap[i--];
+			redMap[j] 	= colorMap[i--];			
+		}		
+	}
+}
diff --git a/src/pixy/image/png/PNGDescriptor.java b/src/pixy/image/png/PNGDescriptor.java
new file mode 100644
index 0000000..40a9ea7
--- /dev/null
+++ b/src/pixy/image/png/PNGDescriptor.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+public class PNGDescriptor {
+	//
+	public static String getCompressionTypeDescrition(int compressionType) {
+		//
+		String description = "";
+		
+		switch(compressionType) {
+			case 0:
+				description = "Deflate/inflate compression with a 32K sliding window";
+				break;
+			default:
+				description = "Invalid compression value";
+				break;
+		}
+		 
+		return description;			 
+	}
+	
+	public static String getFilterDescription(int filter) {
+		//
+		String description = "";
+		
+		switch(filter) {
+			case 0:
+				description = "No filter";
+				break;
+			case 1:
+				description = "SUB filter";
+				break;
+			case 2:
+				description = "UP filter";
+				break;
+			case 3:
+				description = "AVERAGE filter";
+				break;
+			case 4:
+				description = "PAETH filter";
+				break;
+			default:
+				description = "Invalid filter type";
+				break;
+		}
+	
+		return description;		
+	}
+	
+	public static String getFilterTypeDescription(int filterType) {
+		//
+		String description = "";
+		
+		switch(filterType) {
+			case 0:
+				description = "Adaptive filtering with five basic filter types";
+				break;
+			default:
+				description = "Invalid filter type";
+				break;
+		}
+		 
+		return description;		
+	}
+	
+	public static String getInterlaceTypeDescription(int interlaceType) {
+		//
+		String description = "";
+		
+		switch(interlaceType) {
+			case 0:
+				description = "No interlace";
+				break;
+			case 1:
+				description = "Adam7 interlace";
+				break;
+			default:
+				description = "Invalid interlace type";
+				break;
+		}
+
+		return description;		
+	}
+	
+	private PNGDescriptor() {}
+}
diff --git a/src/pixy/image/png/SRGBBuilder.java b/src/pixy/image/png/SRGBBuilder.java
new file mode 100644
index 0000000..b7dee14
--- /dev/null
+++ b/src/pixy/image/png/SRGBBuilder.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import pixy.util.Builder;
+
+/**
+ * PNG sRGB chunk builder
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 10/16/2013
+ */
+public class SRGBBuilder extends ChunkBuilder implements Builder<Chunk> {
+	
+	private byte renderingIntent;
+
+	public SRGBBuilder renderingIntent(byte renderingIntent) {
+		if (renderingIntent < 0 || renderingIntent > 3) 
+			throw new IllegalArgumentException("Invalid rendering intent: " + renderingIntent);
+		this.renderingIntent = renderingIntent;
+		return this;
+	}
+	
+	public SRGBBuilder() {
+		super(ChunkType.SRGB);		
+	}	
+
+	@Override
+	protected byte[] buildData() {
+		// 1 bytes
+		byte[] data = {renderingIntent};
+		
+		return data;
+	}
+}
diff --git a/src/pixy/image/png/SRGBReader.java b/src/pixy/image/png/SRGBReader.java
new file mode 100644
index 0000000..7fdaf29
--- /dev/null
+++ b/src/pixy/image/png/SRGBReader.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.io.IOException;
+
+import pixy.util.Reader;
+
+/**
+ * PNG sRGB chunk reader
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 10/16/2013
+ */
+public class SRGBReader implements Reader {
+
+	private Chunk chunk;
+	private byte renderingIntent;
+	
+	public SRGBReader(Chunk chunk) {
+		if(chunk == null) throw new IllegalArgumentException("Input chunk is null");
+		
+		if (chunk.getChunkType() != ChunkType.SRGB) {
+			throw new IllegalArgumentException("Not a valid sRGB chunk.");
+		}
+		
+		this.chunk = chunk;
+		
+		try {
+			read();
+		} catch (IOException e) {
+			throw new RuntimeException("SRGBReader: error reading chunk");
+		}
+	}
+	
+	/**
+	 * sRGB rendering intent:
+	 * <p>
+	 * 0 - Perceptual:
+	 * for images preferring good adaptation to the output device gamut at the expense of
+	 * colorimetric accuracy, such as photographs.
+	 * <p>
+	 * 1 - Relative colorimetric:
+	 * for images requiring colour appearance matching (relative to the output device white point),
+	 * such as logos.
+	 * <p>
+	 * 2 - Saturation:
+	 * for images preferring preservation of saturation at the expense of hue and lightness,
+	 * such as charts and graphs.
+	 * <p>
+	 * 3 - Absolute colorimetric:
+	 * for images requiring preservation of absolute colorimetry, such as previews of images destined
+	 * for a different output device (proofs).
+	 */
+	public byte getRenderingIntent() {
+		return renderingIntent;
+	}
+
+	public void read() throws IOException {
+		byte[] data = chunk.getData();
+		if(data.length > 0)
+			renderingIntent = data[0]; 
+		else renderingIntent = -1;
+	}
+}
diff --git a/src/pixy/image/png/TIMEBuilder.java b/src/pixy/image/png/TIMEBuilder.java
new file mode 100644
index 0000000..6cc99dc
--- /dev/null
+++ b/src/pixy/image/png/TIMEBuilder.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.util.Calendar;
+
+import pixy.util.Builder;
+
+public class TIMEBuilder extends ChunkBuilder implements Builder<Chunk> {
+	//
+	private int year;
+	private int month;
+	private int day;
+	private int hour;
+	private int minute;
+	private int second;
+	
+	public TIMEBuilder() {
+		super(ChunkType.TIME);
+	}
+	
+	public TIMEBuilder calendar(Calendar calendar) {
+		this.year = calendar.get(Calendar.YEAR);
+		this.month = calendar.get(Calendar.MONTH) + 1;
+		this.day = calendar.get(Calendar.DAY_OF_MONTH);
+		this.hour = calendar.get(Calendar.HOUR_OF_DAY);
+		this.minute = calendar.get(Calendar.MINUTE);
+		this.second = calendar.get(Calendar.SECOND);
+		
+		return this;
+	}
+	
+	public TIMEBuilder year(int year) {
+		if(year > Short.MAX_VALUE || year < Short.MIN_VALUE)
+			throw new IllegalArgumentException("Year out of range: " + Short.MIN_VALUE + " - " +  Short.MAX_VALUE);
+		this.year = year;
+		return this;
+	}
+	
+	public TIMEBuilder month(int month) {
+		if(month > 12 || month < 1)
+			throw new IllegalArgumentException("Month out of range: " + 1 + "-" + 12);
+		this.month = month;
+		return this;
+	}
+	
+	public TIMEBuilder day(int day) {
+		if(day > 31 || day < 1)
+			throw new IllegalArgumentException("Day out of range: " + 1 + "-" + 31);
+		this.day = day;
+		return this;
+	}
+	
+	public TIMEBuilder hour(int hour) {
+		if(hour > 23 || hour < 0)
+			throw new IllegalArgumentException("Hour out of range: " + 0 + "-" + 23);
+		this.hour = hour;
+		return this;
+	}
+	
+	public TIMEBuilder minute(int minute) {
+		if(minute > 59 || minute < 0)
+			throw new IllegalArgumentException("Minute out of range: " + 0 + "-" + 59);
+		this.minute = minute;
+		return this;
+	}
+	
+	public TIMEBuilder second(int second) {
+		if(second > 60 || second < 0)
+			throw new IllegalArgumentException("Second out of range: " + 0 + "-" + 60);
+		this.second = second;
+		return this;
+	}
+	
+	/**
+	 * Build the byte array representation of tIME chunk.
+	 * <p>
+	 * The tIME chunk gives the time of the last image modification (not the time of initial image creation). It contains: 
+	 * <pre>
+	 *  Year:   2 bytes (complete; for example, 1995, not 95)
+	 *  Month:  1 byte (1-12)
+	 *  Day:    1 byte (1-31)
+	 *  Hour:   1 byte (0-23)
+	 *  Minute: 1 byte (0-59)
+	 *  Second: 1 byte (0-60) (yes, 60, for leap seconds; not 61, a common error)
+	 *  </pre>
+	 */   
+	@Override
+	protected byte[] buildData() {
+		return new byte[] {(byte)(year >>> 8), (byte)year, (byte)month, (byte)day, (byte)hour, (byte)minute, (byte)second};
+	}
+}
diff --git a/src/pixy/image/png/TIMEReader.java b/src/pixy/image/png/TIMEReader.java
new file mode 100644
index 0000000..cb8c119
--- /dev/null
+++ b/src/pixy/image/png/TIMEReader.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.io.IOException;
+
+import pixy.util.Reader;
+
+public class TIMEReader implements Reader {
+	//
+	private int year;
+	private int month;
+	private int day;
+	private int hour;
+	private int minute;
+	private int second;
+	private Chunk chunk;
+	
+	public TIMEReader(Chunk chunk) {
+		if(chunk == null) throw new IllegalArgumentException("Input chunk is null");
+		
+		if (chunk.getChunkType() != ChunkType.TIME) {
+			throw new IllegalArgumentException("Not a valid tIME chunk.");
+		}
+		
+		this.chunk = chunk;
+		
+		try {
+			read();
+		} catch (IOException e) {
+			throw new RuntimeException("TIMEReader: error reading chunk");
+		}
+	}
+	
+	public int getDay() {
+		return day;
+	}
+	
+	public int getHour() {
+		return hour;
+	}
+	
+	public int getMinute() {
+		return minute;
+	}
+	
+	public int getMonth() {
+		return month;
+	}
+	
+	public int getSecond() {
+		return second;
+	}
+	
+	public int getYear() {
+		return year;		
+	}
+	
+	/**
+	 * Read the tIME chunk.
+	 * <p>
+	 * The tIME chunk gives the time of the last image modification (not the time of initial image creation). It contains: 
+	 * <pre>
+	 *  Year:   2 bytes (complete; for example, 1995, not 95)
+	 *  Month:  1 byte (1-12)
+	 *  Day:    1 byte (1-31)
+	 *  Hour:   1 byte (0-23)
+	 *  Minute: 1 byte (0-59)
+	 *  Second: 1 byte (0-60) (yes, 60, for leap seconds; not 61, a common error)
+	 *  </pre>
+	 */   
+	public void read() throws IOException {
+		byte[] data = chunk.getData();
+		
+		if(data.length < 7) throw new RuntimeException("TimeReader: input data too short");
+		
+		this.year = ((data[0]&0xff) << 8 | data[1]&0xff); // Unsigned short (Motorola)
+		this.month = data[2]&0xff;
+		this.day = data[3]&0xff;
+		this.hour = data[4]&0xff;
+		this.minute = data[5]&0xff;
+		this.second = data[6]&0xff;
+	}
+}
diff --git a/src/pixy/image/png/TRNSBuilder.java b/src/pixy/image/png/TRNSBuilder.java
new file mode 100644
index 0000000..f8e4f17
--- /dev/null
+++ b/src/pixy/image/png/TRNSBuilder.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import pixy.util.Builder;
+
+/**
+ * PNG tRNS chunk builder
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 05/03/2013
+ */
+public class TRNSBuilder extends ChunkBuilder implements Builder<Chunk> {
+
+	private int colorType = 0;
+	private byte[] alpha;
+	
+	public TRNSBuilder(int colorType) {
+		super(ChunkType.TRNS);
+		this.colorType = colorType;
+	}
+	
+	public TRNSBuilder alpha(byte[] alpha) {
+		this.alpha = alpha;
+		return this;
+	}
+
+	@Override
+	protected byte[] buildData() {
+		switch(colorType)
+		{			
+			case 0:	
+			case 2:				
+			case 3:			
+				break;
+			case 4:
+			case 6:		
+			default:
+				throw new IllegalArgumentException("Invalid color type: " + colorType);
+		}
+		
+		return alpha;
+	}
+}
diff --git a/src/pixy/image/png/TRNSReader.java b/src/pixy/image/png/TRNSReader.java
new file mode 100644
index 0000000..9071594
--- /dev/null
+++ b/src/pixy/image/png/TRNSReader.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.io.IOException;
+
+import pixy.util.Reader;
+
+/**
+ * PNG tRNS chunk reader
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 05/03/2013
+ */
+public class TRNSReader implements Reader {
+
+	private byte[] alpha = new byte[0];
+	private Chunk chunk;
+	
+	public TRNSReader(Chunk chunk) {
+		if(chunk == null) throw new IllegalArgumentException("Input chunk is null");
+		
+		if (chunk.getChunkType() != ChunkType.TRNS) {
+			throw new IllegalArgumentException("Not a valid TRNS chunk.");
+		}
+		
+		this.chunk = chunk;
+		
+		try {
+			read();
+		} catch (IOException e) {
+			throw new RuntimeException("TRNSReader: error reading chunk");
+		}
+	}
+	
+	public byte[] getAlpha() {
+		return alpha;
+	}
+
+	public void read() throws IOException {
+		this.alpha = chunk.getData();
+	}
+}
diff --git a/src/pixy/image/png/TextBuilder.java b/src/pixy/image/png/TextBuilder.java
new file mode 100644
index 0000000..27755bf
--- /dev/null
+++ b/src/pixy/image/png/TextBuilder.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.io.BufferedWriter;
+import java.io.OutputStreamWriter;
+import java.util.zip.DeflaterOutputStream;
+import java.io.ByteArrayOutputStream;
+
+import pixy.util.Builder;
+
+/**
+ * Builder for PNG textual chunks: iTXT, zTXT, and tEXT.
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 12/31/2012
+ */
+public class TextBuilder extends ChunkBuilder implements Builder<Chunk> {
+	// Whether or not iTXT should be compressed. iTXT is uncompressed by default
+	private boolean compressed;
+	private String keyword;
+	private String text;
+	
+	public TextBuilder(ChunkType chunkType) {
+		super(chunkType);
+		
+		if((chunkType != ChunkType.TEXT) && (chunkType != ChunkType.ITXT) 
+				&& (chunkType != ChunkType.ZTXT))
+			throw new IllegalArgumentException("Expect Textual chunk!");
+	}
+	
+	protected byte[] buildData() {	
+		byte[] data = null;
+		ChunkType chunkType = getChunkType();
+		
+		StringBuilder sb = new StringBuilder(this.keyword);
+		sb.append('\0');
+		
+		switch (chunkType) {
+			case TEXT:
+				sb.append(this.text);
+				try {
+					data = sb.toString().getBytes("iso-8859-1");
+				} catch (Exception ex) {
+					ex.printStackTrace();
+				}					
+				break;
+			case ZTXT:
+				try {
+					ByteArrayOutputStream bo = new ByteArrayOutputStream(1024);
+					sb.append('\0');
+					bo.write(sb.toString().getBytes("iso-8859-1"));
+					DeflaterOutputStream ds = new DeflaterOutputStream(bo);
+					OutputStreamWriter or = new OutputStreamWriter(ds, "iso-8859-1");
+	                BufferedWriter br = new BufferedWriter(or);                       
+					br.write(this.text);
+					br.flush();
+					br.close();
+					data = bo.toByteArray();					
+				} catch (Exception ex) {
+					ex.printStackTrace();
+				}
+	            break;
+			case ITXT:
+				try {
+					ByteArrayOutputStream bo = new ByteArrayOutputStream(1024);
+					bo.write(sb.toString().getBytes("iso-8859-1"));
+					OutputStreamWriter or = null;
+					if(compressed) {
+						bo.write(new byte[]{1, 0, 0, 0});
+						or = new OutputStreamWriter(new DeflaterOutputStream(bo), "UTF-8");
+					} else {
+						bo.write(new byte[]{0, 0, 0, 0});
+						or = new OutputStreamWriter(bo, "UTF-8");
+					}
+					BufferedWriter br = new BufferedWriter(or);
+					br.write(this.text);
+					br.flush();
+					br.close();
+					data = bo.toByteArray();					
+				} catch (Exception ex) {
+					ex.printStackTrace();
+				}
+				break;
+			default: // It will never come this far!				
+		}
+		
+	    return data;
+	}
+	
+	/**
+	 * The keyword must be at least one character and less than 80 characters long.
+	 * <p>
+	 * Keywords are always interpreted according to the ISO/IEC 8859-1 (Latin-1) 
+	 * character set [ISO/IEC-8859-1]. 
+	 * <p>
+	 * They must contain only printable Latin-1 characters and spaces; that is, 
+	 * only character codes 32-126 and 161-255 decimal are allowed. 
+	 * <p>
+	 * To reduce the chances for human misreading of a keyword, leading and 
+	 * trailing spaces are forbidden, as are consecutive spaces.
+	 * <p>
+	 * Note also that the non-breaking space (code 160) is not permitted in keywords,
+	 * since it is visually indistinguishable from an ordinary space.
+	 */
+	public TextBuilder keyword(String keyword) {
+		this.keyword = keyword.trim().replaceAll("\\s+", " ");
+		return this;
+	}
+	
+	public void setCompressed(boolean compressed) {
+		this.compressed = compressed;
+	}
+
+	/**
+	 * The tExt chunk is interpreted according to the ISO/IEC 8859-1 (Latin-1) character 
+	 * set [ISO/IEC-8859-1].
+	 * 
+	 * The text string can contain any Latin-1 character. Newlines in the text string 
+	 * should be represented by a single line feed character (decimal 10); use of other
+	 * control characters in the text is discouraged.
+	 * <p>
+	 * The zTXt chunk contains textual data, just as tEXt does; however, zTXt takes 
+	 * advantage of compression. The zTXt and tEXt chunks are semantically equivalent,
+	 * but zTXt is recommended for storing large blocks of text.
+     * A zTXt chunk contains:
+     *   Keyword:            1-79 bytes (character string)
+     *   Null separator:     1 byte
+     *   Compression method: 1 byte
+     *   Compressed text:    n bytes
+	 * <p>
+	 * iTXt International textual data
+     * This chunk is semantically equivalent to the tEXt and zTXt chunks, but the textual
+     * data is in the UTF-8 encoding of the Unicode character set instead of Latin-1. 
+     * This chunk contains:
+     *    Keyword:             1-79 bytes (character string)
+     *    Null separator:      1 byte
+     *    Compression flag:    1 byte
+     *    Compression method:  1 byte
+     *    Language tag:        0 or more bytes (character string)
+     *    Null separator:      1 byte
+     *    Translated keyword:  0 or more bytes
+     *    Null separator:      1 byte
+     *    Text:                0 or more bytes
+	 */
+	public TextBuilder text(String text) {
+		this.text = text;
+		return this;
+	}
+}
diff --git a/src/pixy/image/png/TextReader.java b/src/pixy/image/png/TextReader.java
new file mode 100644
index 0000000..ed1e609
--- /dev/null
+++ b/src/pixy/image/png/TextReader.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.zip.InflaterInputStream;
+
+import pixy.util.Reader;
+
+/**
+ * Reader for PNG textual chunks: iTXT, zTXT, and tEXT.
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/01/2013
+ */
+public class TextReader implements Reader {
+	//
+	private String keyword;
+	private String text;
+	private Chunk chunk;
+	
+	public TextReader() {
+		; // Default constructor
+	}
+	
+	public TextReader(Chunk chunk) {
+		setInput(chunk);
+	}
+	
+	public String getKeyword() {
+		return this.keyword;
+	}
+	
+	public String getText() {
+		return text;
+	}
+	
+	// Read text chunks to a String
+   	public void read() throws IOException {       
+   		StringBuilder sb = new StringBuilder(1024);
+   		byte[] data = chunk.getData();
+   		
+        switch (chunk.getChunkType()) {
+		   case ZTXT:
+		   {   					  
+			   int keyword_len = 0;
+			   while(data[keyword_len]!=0) keyword_len++;
+			   this.keyword = new String(data,0,keyword_len,"UTF-8");
+			
+			   InflaterInputStream ii = new InflaterInputStream(new ByteArrayInputStream(data,keyword_len+2, data.length-keyword_len-2));
+			   InputStreamReader ir = new InputStreamReader(ii,"UTF-8");
+               BufferedReader br = new BufferedReader(ir);                       
+			   String read = null;
+               while((read=br.readLine()) != null) {
+                  sb.append(read);
+                  sb.append("\n");
+               }                  
+			   br.close();
+	
+               break;
+           }
+		   case TEXT:
+		   {
+			   int keyword_len = 0;			   
+			   while(data[keyword_len]!=0) keyword_len++;
+			   this.keyword = new String(data,0,keyword_len,"UTF-8");
+			   sb.append(new String(data,keyword_len+1,data.length-keyword_len-1,"UTF-8"));
+			
+               break;
+		   }
+		   case ITXT:
+           {
+			   // System.setOut(new PrintStream(new File("TextChunk.txt"),"UTF-8"));
+               /**
+			    * Keyword:             1-79 bytes (character string)
+			    * Null separator:      1 byte
+			    * Compression flag:    1 byte
+			    * Compression method:  1 byte
+			    * Language tag:        0 or more bytes (character string)
+			    * Null separator:      1 byte
+			    * Translated keyword:  0 or more bytes
+			    * Null separator:      1 byte
+			    * Text:                0 or more bytes
+			    */
+			   int keyword_len = 0;
+			   int trans_keyword_len = 0;
+			   int lang_flg_len = 0;
+			   boolean compr = false;
+			   while(data[keyword_len]!=0) keyword_len++;
+			   sb.append(new String(data,0,keyword_len,"UTF-8"));
+			   if(data[++keyword_len]==1) compr = true;
+			   keyword_len++;//Skip the compression method byte.
+               while(data[++keyword_len]!=0) lang_flg_len++;
+			   //////////////////////
+			   sb.append("(");
+			   if(lang_flg_len>0)
+				   sb.append(new String(data,keyword_len-lang_flg_len, lang_flg_len, "UTF-8"));
+			   while(data[++keyword_len]!=0) trans_keyword_len++;
+               if(trans_keyword_len>0) {
+				   sb.append(" ");
+            	   sb.append(new String(data,keyword_len-trans_keyword_len, trans_keyword_len, "UTF-8"));
+               }
+			   sb.append(")");
+			   
+			   this.keyword = sb.toString().replaceFirst("\\(\\)", "");
+			   
+			   sb.setLength(0); // Reset StringBuilder
+			   /////////////////////// End of key.
+			   if(compr) {//Compressed text
+				   InflaterInputStream ii = new InflaterInputStream(new ByteArrayInputStream(data,keyword_len+1, data.length-keyword_len-1));
+				   InputStreamReader ir = new InputStreamReader(ii,"UTF-8");
+				   BufferedReader br = new BufferedReader(ir);                       
+				   String read = null;
+				  
+				   while((read=br.readLine()) != null) {
+					  sb.append(read);
+					  sb.append("\n");
+				   }
+				  
+				   br.close();
+			   } else { //Uncompressed text
+				   sb.append(new String(data,keyword_len+1,data.length-keyword_len-1,"UTF-8"));
+				   sb.append("\n");
+			   }
+			   
+			   sb.deleteCharAt(sb.length() - 1);
+			   
+			   break;
+		   }			   
+
+           default:
+               throw new IllegalArgumentException("Not a valid textual chunk.");
+        }
+        this.text = sb.toString();
+     }
+   	
+   	public void setInput(Chunk chunk) {
+   		validate(chunk);		
+		this.chunk = chunk;
+		try {
+			read();
+		} catch (IOException e) {
+			throw new RuntimeException("TextReader: error reading chunk");
+		}
+   	}
+   	
+   	private static void validate(Chunk chunk) {
+   		if(chunk == null) throw new IllegalArgumentException("Input chunk is null");
+		
+		if (chunk.getChunkType() != ChunkType.TEXT && chunk.getChunkType() != ChunkType.ZTXT &&
+				chunk.getChunkType() != ChunkType.ITXT) {
+			throw new IllegalArgumentException("Not a valid textual chunk.");
+		}
+   	}
+}
diff --git a/src/pixy/image/png/UnknownChunk.java b/src/pixy/image/png/UnknownChunk.java
new file mode 100644
index 0000000..f5c64f8
--- /dev/null
+++ b/src/pixy/image/png/UnknownChunk.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import pixy.io.IOUtils;
+
+/**
+ * Special chunk to handle PNG ChunkType.UNKNOWN.
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 12/30/2012
+ */
+public class UnknownChunk extends Chunk {
+
+	private final int chunkValue;
+	
+	public UnknownChunk(long length, int chunkValue, byte[] data, long crc) {
+		super(ChunkType.UNKNOWN,length, data, crc);
+		this.chunkValue = chunkValue;
+	}
+	
+	public int getChunkValue(){
+		return chunkValue;
+	}
+	
+	@Override public boolean isValidCRC() {				 
+		return (calculateCRC(chunkValue, getData()) == getCRC());
+	}
+	
+	@Override public void write(OutputStream os) throws IOException{
+		IOUtils.writeIntMM(os, (int)getLength());
+		IOUtils.writeIntMM(os, this.chunkValue);
+		IOUtils.write(os, getData());
+		IOUtils.writeIntMM(os, (int)getCRC());
+	}
+	
+	@Override public String toString() {
+		return super.toString() + "[Chunk type value: 0x"+ Integer.toHexString(chunkValue)+"]";
+	}	
+}
diff --git a/src/pixy/image/png/UnknownChunkBuilder.java b/src/pixy/image/png/UnknownChunkBuilder.java
new file mode 100644
index 0000000..4aa5968
--- /dev/null
+++ b/src/pixy/image/png/UnknownChunkBuilder.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import pixy.util.Builder;
+
+/**
+ * Special chunk builder for UnknownChunk.
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 12/31/2012
+ */
+public class UnknownChunkBuilder implements Builder<Chunk> {
+
+	private int chunkType;
+	private byte[] data;
+	
+	public UnknownChunkBuilder type(int type) {
+		this.chunkType = type;
+		return this;
+	}
+	
+	public UnknownChunkBuilder data(byte[] data) {
+		this.data = data;
+		return this;
+	}
+	
+	public UnknownChunkBuilder() {}
+
+	public Chunk build() {
+		long crc = Chunk.calculateCRC(chunkType, data);
+	    
+	    return new UnknownChunk(data.length, chunkType, data, crc);	
+	}
+}
diff --git a/src/pixy/image/png/UnknownChunkReader.java b/src/pixy/image/png/UnknownChunkReader.java
new file mode 100644
index 0000000..1324c64
--- /dev/null
+++ b/src/pixy/image/png/UnknownChunkReader.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.png;
+
+import java.io.IOException;
+
+import pixy.util.Reader;
+
+/**
+ * Special chunk reader for UnknownChunk.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/01/2013
+ */
+public class UnknownChunkReader implements Reader {
+
+	private int chunkValue;
+	private byte[] data;
+	private Chunk chunk;
+		
+	public UnknownChunkReader(Chunk chunk) {
+		if(chunk == null) throw new IllegalArgumentException("Input chunk is null");
+		
+		this.chunk = chunk;
+		
+		try {
+			read();
+		} catch (IOException e) {
+			throw new RuntimeException("UnknownChunkReader: error reading chunk");
+		}
+	}
+	
+	public int getChunkValue() {
+		return this.chunkValue;
+	}
+	
+	public byte[] getData() {
+		return data;
+	}
+	
+	public void read() throws IOException {       
+   		if (chunk instanceof UnknownChunk) {
+   			UnknownChunk unknownChunk = (UnknownChunk)chunk;
+   			this.chunkValue = unknownChunk.getChunkValue();
+   			this.data = unknownChunk.getData();
+   		} else
+   		    throw new IllegalArgumentException("Expect UnknownChunk.");
+     }
+}
diff --git a/src/pixy/image/tiff/ASCIIField.java b/src/pixy/image/tiff/ASCIIField.java
new file mode 100644
index 0000000..9dcd7f2
--- /dev/null
+++ b/src/pixy/image/tiff/ASCIIField.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+
+import pixy.io.RandomAccessOutputStream;
+
+/**
+ * TIFF ASCII type field.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/06/2013
+ */
+public final class ASCIIField extends TiffField<String> {
+
+	public ASCIIField(short tag, String data) { // ASCII field is NUL- terminated ASCII string
+		super(tag, FieldType.ASCII, getLength(data));
+		this.data = data.trim() + '\0'; // Add NULL to the end of the string
+	}
+	
+	private static int getLength(String data) {
+		try {
+			return data.trim().getBytes("UTF-8").length + 1;
+		} catch (UnsupportedEncodingException e) {
+			throw new RuntimeException("Failed to create ASCIIField.");
+		}
+	}
+	
+	public String getDataAsString() {
+		// ASCII field allows for multiple NUL separated strings
+		return data.trim().replace("\0", "; ");
+	}
+
+	protected int writeData(RandomAccessOutputStream os, int toOffset) throws IOException {
+		
+		byte[] buf = data.getBytes("UTF-8");
+        
+		if (buf.length <= 4) {
+			dataOffset = (int)os.getStreamPointer();
+			byte[] tmp = new byte[4];
+			System.arraycopy(buf, 0, tmp, 0, buf.length);
+			os.write(tmp);
+		} else {
+			dataOffset = toOffset;
+			os.writeInt(toOffset);
+			os.seek(toOffset);
+			os.write(buf);
+			toOffset += buf.length; 
+		}		
+		return toOffset;
+	}
+}
diff --git a/src/pixy/image/tiff/AbstractByteField.java b/src/pixy/image/tiff/AbstractByteField.java
new file mode 100644
index 0000000..00b7a48
--- /dev/null
+++ b/src/pixy/image/tiff/AbstractByteField.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import java.io.IOException;
+
+import pixy.io.RandomAccessOutputStream;
+import pixy.string.StringUtils;
+
+public abstract class AbstractByteField extends TiffField<byte[]> {
+
+	public AbstractByteField(short tag, FieldType fieldType, byte[] data) {
+		super(tag, fieldType, data.length);
+		this.data = data;
+	}
+	
+	public byte[] getData() {
+		return data.clone();
+	}
+	
+	public String getDataAsString() {
+		return StringUtils.byteArrayToHexString(data, 0, MAX_STRING_REPR_LEN);
+	}
+
+	protected int writeData(RandomAccessOutputStream os, int toOffset) throws IOException {
+	
+		if (data.length <= 4) {
+			dataOffset = (int)os.getStreamPointer();
+			byte[] tmp = new byte[4];
+			System.arraycopy(data, 0, tmp, 0, data.length);
+			os.write(tmp);
+		} else {
+			dataOffset = toOffset;
+			os.writeInt(toOffset);
+			os.seek(toOffset);
+			os.write(data);
+			toOffset += data.length;
+		}
+		return toOffset;
+	}
+}
diff --git a/src/pixy/image/tiff/AbstractLongField.java b/src/pixy/image/tiff/AbstractLongField.java
new file mode 100644
index 0000000..fc67d37
--- /dev/null
+++ b/src/pixy/image/tiff/AbstractLongField.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import java.io.IOException;
+
+import pixy.io.RandomAccessOutputStream;
+
+public abstract class AbstractLongField extends TiffField<int[]> {
+
+	public AbstractLongField(short tag, FieldType fieldType, int[] data) {
+		super(tag, fieldType, data.length);	
+		this.data = data;
+	}
+	
+	public int[] getData() {
+		return data.clone();
+	}
+	
+	public int[] getDataAsLong() {
+		return getData();
+	}
+	
+	protected int writeData(RandomAccessOutputStream os, int toOffset) throws IOException {
+		
+		if (data.length == 1) {
+			dataOffset = (int)os.getStreamPointer();
+			os.writeInt(data[0]);
+		} else {
+			dataOffset = toOffset;
+			os.writeInt(toOffset);
+			os.seek(toOffset);
+			
+			for (int value : data)
+				os.writeInt(value);
+			
+			toOffset += (data.length << 2);
+		}
+		return toOffset;
+	}
+}
diff --git a/src/pixy/image/tiff/AbstractRationalField.java b/src/pixy/image/tiff/AbstractRationalField.java
new file mode 100644
index 0000000..e85d315
--- /dev/null
+++ b/src/pixy/image/tiff/AbstractRationalField.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import java.io.IOException;
+
+import pixy.io.RandomAccessOutputStream;
+
+public abstract class AbstractRationalField extends TiffField<int[]> {
+
+	public AbstractRationalField(short tag, FieldType fieldType, int[] data) {
+		super(tag, fieldType, data.length>>1);
+		this.data = data;
+	}
+	
+	public int[] getData() {
+		return data.clone();
+	}
+	
+	public int[] getDataAsLong() {
+		return getData();
+	}
+
+	protected int writeData(RandomAccessOutputStream os, int toOffset) throws IOException {
+		//
+		dataOffset = toOffset;
+		os.writeInt(toOffset);
+		os.seek(toOffset);
+		
+		for (int value : data)
+			os.writeInt(value);
+		
+		toOffset += (data.length << 2);
+		
+		return toOffset;
+	}
+}
diff --git a/src/pixy/image/tiff/AbstractShortField.java b/src/pixy/image/tiff/AbstractShortField.java
new file mode 100644
index 0000000..8339e4e
--- /dev/null
+++ b/src/pixy/image/tiff/AbstractShortField.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import java.io.IOException;
+
+import pixy.io.RandomAccessOutputStream;
+
+public abstract class AbstractShortField extends TiffField<short[]> {
+
+	public AbstractShortField(short tag, FieldType fieldType, short[] data) {
+		super(tag, fieldType, data.length);
+		this.data = data;	
+	}
+	
+	public short[] getData() {
+		return data.clone();
+	}
+
+	protected int writeData(RandomAccessOutputStream os, int toOffset) throws IOException {
+		if (data.length <= 2) {
+			dataOffset = (int)os.getStreamPointer();
+			short[] tmp = new short[2];
+			System.arraycopy(data, 0, tmp, 0, data.length);
+			for (short value : tmp)
+				os.writeShort(value);
+		} else {
+			dataOffset = toOffset;
+			os.writeInt(toOffset);
+			os.seek(toOffset);
+			
+			for (short value : data)
+				os.writeShort(value);
+			
+			toOffset += (data.length << 1);
+		}
+		return toOffset;
+	}
+}
diff --git a/src/pixy/image/tiff/ByteField.java b/src/pixy/image/tiff/ByteField.java
new file mode 100644
index 0000000..8e6bfc9
--- /dev/null
+++ b/src/pixy/image/tiff/ByteField.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+/**
+ * TIFF Byte type field.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/06/2013
+ */
+public final class ByteField extends AbstractByteField {
+
+	public ByteField(short tag, byte[] data) {
+		super(tag, FieldType.BYTE, data);
+	}
+}
diff --git a/src/pixy/image/tiff/DoubleField.java b/src/pixy/image/tiff/DoubleField.java
new file mode 100644
index 0000000..6fd2184
--- /dev/null
+++ b/src/pixy/image/tiff/DoubleField.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import java.io.IOException;
+import java.util.Arrays;
+
+import pixy.io.RandomAccessOutputStream;
+
+/**
+ * TIFF FieldType.DOUBLE wrapper
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 12/04/2014
+ */
+public class DoubleField extends TiffField<double[]> {
+
+	public DoubleField(short tag, double[] data) {
+		super(tag, FieldType.DOUBLE, data.length);
+		this.data = data;
+	}
+	
+	public double[] getData() {
+		return data.clone();
+	}
+	
+	public String getDataAsString() {
+		return Arrays.toString(data);
+	}
+
+	@Override
+	protected int writeData(RandomAccessOutputStream os, int toOffset)
+			throws IOException {
+		//
+		dataOffset = toOffset;
+		os.writeInt(toOffset);
+		os.seek(toOffset);
+		
+		for (double value : data)
+			os.writeDouble(value);
+		
+		toOffset += (data.length << 3);
+		
+		return toOffset;
+	}
+}
diff --git a/src/pixy/image/tiff/FieldType.java b/src/pixy/image/tiff/FieldType.java
new file mode 100644
index 0000000..b68c2fe
--- /dev/null
+++ b/src/pixy/image/tiff/FieldType.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import java.io.UnsupportedEncodingException;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * TIFF field type.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/06/2013
+ */
+public enum FieldType {
+	BYTE("Byte", (short)0x0001),
+	ASCII("ASCII", (short)0x0002),
+	SHORT("Short", (short)0x0003),
+	LONG("Long", (short)0x0004),
+	RATIONAL("Rational", (short)0x0005),
+	SBYTE("SByte", (short)0x0006),
+	UNDEFINED("Undefined", (short)0x0007),
+	SSHORT("SShort", (short)0x0008),
+	SLONG("SLong", (short)0x0009),
+	SRATIONAL("SRational", (short)0x000a),
+	FLOAT("Float", (short)0x000b),
+	DOUBLE("Double", (short)0x000c),
+	IFD("IFD", (short)0x000d),
+	// There two are not actually TIFF defined field type, internally they TIFF BYTE fields
+	WINDOWSXP("WindowsXP", (short)0x000e),
+	EXIF_MAKERNOTE("ExifMakernote", (short)0x000f),
+	
+	UNKNOWN("Unknown", (short)0x0000);
+	
+	private FieldType(String name, short value) {
+		this.name = name;
+		this.value = value;
+	}
+	
+	public static TiffField<?> createField(Tag tag, FieldType type, Object data) {
+		if(data == null) throw new IllegalArgumentException("Input data is null");
+    	TiffField<?> retValue = null;
+    	Class<?> typeClass = data.getClass();
+    	switch(type) {
+    		case ASCII:
+    			if(typeClass == String.class) {
+    				retValue = new ASCIIField(tag.getValue(), (String)data);    				
+    			}
+    			break;
+    		case BYTE:
+    		case SBYTE:
+    		case UNDEFINED:
+    			if(typeClass == byte[].class) {
+    				byte[] byteData = (byte[])data;
+    				if(byteData.length > 0) {
+    					if(type == FieldType.BYTE)
+    						retValue = new ByteField(tag.getValue(), byteData);
+    					else if(type == FieldType.SBYTE)
+    						retValue = new SByteField(tag.getValue(), byteData);
+    					else
+    						retValue = new UndefinedField(tag.getValue(), byteData);
+    				}
+    			}
+    			break;
+    		case SHORT:
+    		case SSHORT:
+    			if(typeClass == short[].class) {
+    				short[] shortData = (short[])data;
+    				if(shortData.length > 0) {
+    					if(type == FieldType.SHORT)
+    						retValue = new ShortField(tag.getValue(), shortData);    
+    					else
+    						retValue = new SShortField(tag.getValue(), shortData); 
+    				}
+    			}
+    			break;
+    		case LONG:
+    		case SLONG:
+    			if(typeClass == int[].class) {
+    				int[] intData = (int[])data;
+    				if(intData.length > 0) {
+    					if(type == FieldType.LONG)
+    						retValue = new LongField(tag.getValue(), intData);
+    					else
+    						retValue = new SLongField(tag.getValue(), intData);
+    				}
+    			}
+    			break;
+    		case RATIONAL:
+    		case SRATIONAL:
+    			if(typeClass == int[].class) {
+    				int[] intData = (int[])data;
+    				if(intData.length > 0 && intData.length % 2 == 0) {
+    					if(type == FieldType.RATIONAL)
+    						retValue = new RationalField(tag.getValue(), intData);
+    					else
+    						retValue = new SRationalField(tag.getValue(), intData);
+    				}
+    			}
+    			break;
+    		case WINDOWSXP: // Not a real TIFF field type, just a convenient way to add Windows XP field as a sting
+    			if(typeClass == String.class) {
+    				try {
+    					byte[] xp = (((String)data).trim() +'\0').getBytes("UTF-16LE");
+						retValue = new ByteField(tag.getValue(), xp); 
+					} catch (UnsupportedEncodingException e) {
+						e.printStackTrace();
+					}    				   				
+    			}
+    			break;
+    		case EXIF_MAKERNOTE:
+    			throw new UnsupportedOperationException("Creating maker note is not supported.");
+    		default:
+    	}
+    	
+		return retValue;
+	}
+	
+	public String getName() {
+		return name;
+	}
+	
+	public short getValue() {
+		return value;
+	}
+	
+	@Override
+    public String toString() {
+		return name;
+	}
+	
+    public static FieldType fromShort(short value) {
+       	FieldType fieldType = typeMap.get(value);
+    	if (fieldType == null)
+    	   return UNKNOWN;
+   		return fieldType;
+    }
+    
+    private static final Map<Short, FieldType> typeMap = new HashMap<Short, FieldType>();
+       
+    static
+    {
+      for(FieldType fieldType : values())
+           typeMap.put(fieldType.getValue(), fieldType);
+    }
+    
+    public static boolean validateData(FieldType type, Object data) {
+    	if(data == null) throw new IllegalArgumentException("Input data is null");
+    	boolean retValue = false;
+    	Class<?> typeClass = data.getClass();
+    	switch(type) {
+    		case ASCII:
+    		case WINDOWSXP: // Not a real TIFF field type, just a convenient way to add Windows XP field as a sting
+				if(typeClass == String.class) {
+					retValue = true;    				
+				}
+				break;
+    		case BYTE:
+    		case SBYTE:
+    		case UNDEFINED:
+    			if(typeClass == byte[].class) {
+    				byte[] byteData = (byte[])data;
+    				if(byteData.length > 0) retValue = true;
+    			}
+    			break;
+    		case SHORT:
+    		case SSHORT:
+    			if(typeClass == short[].class) {
+    				short[] shortData = (short[])data;
+    				if(shortData.length > 0) retValue = true;    				
+    			}
+    			break;
+    		case LONG:
+    		case SLONG:
+    			if(typeClass == int[].class) {
+    				int[] intData = (int[])data;
+    				if(intData.length > 0) retValue = true;    				
+    			}
+    			break;
+    		case RATIONAL:
+    		case SRATIONAL:
+    			if(typeClass == int[].class) {
+    				int[] intData = (int[])data;
+    				if(intData.length > 0 && intData.length % 2 == 0) retValue = true;  				
+    			}
+    			break;
+    		default:
+    	}
+    	
+		return retValue;    	
+    }
+	
+	private final String name;
+	private final short value;
+}
diff --git a/src/pixy/image/tiff/FloatField.java b/src/pixy/image/tiff/FloatField.java
new file mode 100644
index 0000000..1975e64
--- /dev/null
+++ b/src/pixy/image/tiff/FloatField.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import java.io.IOException;
+import java.util.Arrays;
+
+import pixy.io.RandomAccessOutputStream;
+
+/**
+ * TIFF FieldType.FLOAT wrapper
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 12/04/2014
+ */
+public class FloatField extends TiffField<float[]> {
+
+	public FloatField(short tag, float[] data) {
+		super(tag, FieldType.FLOAT, data.length);
+		this.data = data;
+	}
+	
+	public float[] getData() {
+		return data.clone();
+	}
+	
+	public String getDataAsString() {
+		return Arrays.toString(data);
+	}
+
+	protected int writeData(RandomAccessOutputStream os, int toOffset) throws IOException {
+		if (data.length == 1) {
+			dataOffset = (int)os.getStreamPointer();
+			os.writeFloat(data[0]);
+		} else {
+			dataOffset = toOffset;
+			os.writeInt(toOffset);
+			os.seek(toOffset);
+			
+			for (float value : data)
+				os.writeFloat(value);
+			
+			toOffset += (data.length << 2);
+		}
+		return toOffset;
+	}
+}
diff --git a/src/pixy/image/tiff/IFD.java b/src/pixy/image/tiff/IFD.java
new file mode 100644
index 0000000..f9f5ec1
--- /dev/null
+++ b/src/pixy/image/tiff/IFD.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * IFD.java
+ *
+ * Who   Date       Description
+ * ====  =========  =======================================================================
+ * WY    15Dec2014  Added removeChild() method
+ * WY    24Nov2014  Added getChild() method
+ * WY    02Apr2014  Added setNextIFDOffset() to work with the case of non-contiguous IFDs
+ * WY    30Mar2014  Added children map, changed write() method to write child nodes as well.
+ */
+
+package pixy.image.tiff;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import pixy.io.RandomAccessOutputStream;
+import pixy.string.StringUtils;
+
+/**
+ * Image File Directory
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/04/2013
+ */
+public final class IFD {
+		
+	/**
+	 * Create a children map for sub IFDs. A sub IFD is associated with a tag of the current IFD
+	 * which serves as pointer to the sub IFD.
+	 */	 
+	private Map<Tag, IFD> children = new HashMap<Tag, IFD>();
+	
+	/** Create a fields map to hold all of the fields for this IFD */
+	private Map<Short, TiffField<?>> tiffFields = new HashMap<Short, TiffField<?>>();
+
+	private int endOffset;
+	
+	private int startOffset;
+	
+	public IFD() {}
+	
+	// Copy constructor
+	public IFD(IFD other) {
+		// Defensive copy
+		this.children = Collections.unmodifiableMap(other.children);
+		this.tiffFields = Collections.unmodifiableMap(other.tiffFields);
+		this.startOffset = other.startOffset;
+		this.endOffset = other.endOffset;
+	}
+	
+	public void addChild(Tag tag, IFD child) {
+		children.put(tag, child);
+	}
+	
+	public void addField(TiffField<?> tiffField) {
+		tiffFields.put(tiffField.getTag(), tiffField);
+	}
+	
+	public void addFields(Collection<TiffField<?>> tiffFields) {
+		for(TiffField<?> field : tiffFields) {
+			addField(field);
+		}
+	}
+	
+	public IFD getChild(Tag tag) {
+		return children.get(tag);
+	}
+	
+	public Map<Tag, IFD> getChildren() {
+		return Collections.unmodifiableMap(children);
+	}
+	
+	public int getEndOffset() {
+		return endOffset;
+	}
+	
+	public TiffField<?> getField(Tag tag) {
+		return tiffFields.get(tag.getValue());
+	}
+	
+	/**
+	 * Return a String representation of the field 
+	 * @param tag Tag for the field
+	 * @return a String representation of the field
+	 */
+	public String getFieldAsString(Tag tag) {
+		TiffField<?> field = tiffFields.get(tag.getValue());
+		if(field != null) {
+			FieldType ftype = field.getType();
+			String suffix = null;
+			if(ftype == FieldType.SHORT || ftype == FieldType.SSHORT)
+				suffix = tag.getFieldAsString(field.getDataAsLong());
+			else
+				suffix = tag.getFieldAsString(field.getData());			
+			
+			return field.getDataAsString() + (StringUtils.isNullOrEmpty(suffix)?"":" => " + suffix);
+		}
+		return "";
+	}
+	
+	/** Get all the fields for this IFD from the internal map. */
+	public Collection<TiffField<?>> getFields() {
+		return Collections.unmodifiableCollection(tiffFields.values());
+	}
+	
+	public int getSize() {
+		return tiffFields.size();
+	}
+	
+	public int getStartOffset() {
+		return startOffset;
+	}
+	
+	/** Remove all the entries from the IDF fields map */
+	public void removeAllFields() {
+		tiffFields.clear();
+	}
+	
+	public IFD removeChild(Tag tag) {
+		return children.remove(tag);
+	}
+	
+	/** Remove a specific field associated with the given tag */
+	public TiffField<?> removeField(Tag tag) {
+		return tiffFields.remove(tag.getValue());
+	}
+	
+	/**
+	 * Set the next IFD offset pointer
+	 * <p>
+	 * Note: This should <em>ONLY</em> be called
+	 * after the current IFD has been written to the RandomAccessOutputStream
+	 *  
+	 * @param os RandomAccessOutputStream
+	 * @param nextOffset next IFD offset value
+	 * @throws IOException
+	 */
+	public void setNextIFDOffset(RandomAccessOutputStream os, int nextOffset) throws IOException {
+		os.seek(endOffset - 4);
+		os.writeInt(nextOffset);
+	}
+	
+	/** Write this IFD and all the children, if any, to the output stream
+	 * 
+	 * @param os RandomAccessOutputStream
+	 * @param offset stream offset to write this IFD
+	 * 
+	 * @throws IOException
+	 */
+	public int write(RandomAccessOutputStream os, int offset) throws IOException {
+		startOffset = offset;
+		// Write this IFD and its children, if any, to the RandomAccessOutputStream
+		List<TiffField<?>> list = new ArrayList<TiffField<?>>(tiffFields.values());
+		// Make sure tiffFields are in incremental order.
+		Collections.sort(list);
+		os.seek(offset);
+		os.writeShort(list.size());
+		offset += 2;
+		endOffset = offset + list.size() * 12 + 4;			
+		// The first available offset to write tiffFields. 
+		int toOffset = endOffset;
+		os.seek(offset); // Set first field offset.
+				
+		for (TiffField<?> tiffField : list)
+		{
+			toOffset = tiffField.write(os, toOffset);
+			offset += 12; // Move to next field. Each field is of fixed length 12.
+			os.seek(offset); // Reset position to next directory field.
+		}
+		
+		/* Set the stream position at the end of the IFD to update
+		 * next IFD offset
+		 */
+		os.seek(offset);
+		os.writeInt(0);	// Set next IFD offset to default 0 
+		
+		// Write sub IFDs if any (we assume bare-bone sub IFDs pointed by long field type with no image data associated)
+		if(children.size() > 0) {
+			for (Map.Entry<Tag, IFD> entry : children.entrySet()) {
+			    Tag key = entry.getKey();
+			    IFD value = entry.getValue();
+			    // Update parent field if present, otherwise skip
+			    TiffField<?> tiffField = this.getField(key);
+			    if(tiffField != null) {
+			    	int dataPos = tiffField.getDataOffset();
+					os.seek(dataPos);
+					os.writeInt(toOffset);
+					os.seek(toOffset);
+					toOffset = value.write(os, toOffset);
+			    }
+		    }
+		}
+			
+		return toOffset;
+	}
+}
diff --git a/src/pixy/image/tiff/IFDField.java b/src/pixy/image/tiff/IFDField.java
new file mode 100644
index 0000000..d8b7585
--- /dev/null
+++ b/src/pixy/image/tiff/IFDField.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import pixy.string.StringUtils;
+
+/**
+ * TIFF IFD type field.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 06/20/2014
+ */
+public final class IFDField extends AbstractLongField {
+
+	public IFDField(short tag, int[] data) {
+		super(tag, FieldType.IFD, data);
+	}
+	
+	public String getDataAsString() {
+		return StringUtils.longArrayToString(data, 0, MAX_STRING_REPR_LEN, true);
+	}
+}
diff --git a/src/pixy/image/tiff/LongField.java b/src/pixy/image/tiff/LongField.java
new file mode 100644
index 0000000..3212a3b
--- /dev/null
+++ b/src/pixy/image/tiff/LongField.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import pixy.string.StringUtils;
+
+/**
+ * TIFF Long type field.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/06/2013
+ */
+public final class LongField extends AbstractLongField {
+
+	public LongField(short tag, int[] data) {
+		super(tag, FieldType.LONG, data);
+	}
+	
+	public String getDataAsString() {
+		return StringUtils.longArrayToString(data, 0, MAX_STRING_REPR_LEN, true);
+	}
+}
diff --git a/src/pixy/image/tiff/MakerNoteField.java b/src/pixy/image/tiff/MakerNoteField.java
new file mode 100644
index 0000000..df8e559
--- /dev/null
+++ b/src/pixy/image/tiff/MakerNoteField.java
@@ -0,0 +1,44 @@
+package pixy.image.tiff;
+
+import java.io.IOException;
+
+import pixy.io.RandomAccessOutputStream;
+import pixy.meta.exif.ExifTag;
+import pixy.string.StringUtils;
+
+public class MakerNoteField extends TiffField<byte[]> {
+
+	public MakerNoteField(byte[] data) {
+		this(null, data);
+	}
+	
+	public MakerNoteField(IFD parent, byte[] data) {
+		super(parent, ExifTag.MAKER_NOTE.getValue(), FieldType.EXIF_MAKERNOTE, data.length);
+		this.data = data;
+	}
+	
+	public byte[] getData() {
+		return data.clone();
+	}
+	
+	public String getDataAsString() {
+		return StringUtils.byteArrayToHexString(data, 0,  MAX_STRING_REPR_LEN);
+	}
+	
+	protected int writeData(RandomAccessOutputStream os, int toOffset) throws IOException {
+	
+		if (data.length <= 4) {
+			dataOffset = (int)os.getStreamPointer();
+			byte[] tmp = new byte[4];
+			System.arraycopy(data, 0, tmp, 0, data.length);
+			os.write(tmp);
+		} else {
+			dataOffset = toOffset;
+			os.writeInt(toOffset);
+			os.seek(toOffset);
+			os.write(data);
+			toOffset += data.length;
+		}
+		return toOffset;
+	}
+}
diff --git a/src/pixy/image/tiff/RationalField.java b/src/pixy/image/tiff/RationalField.java
new file mode 100644
index 0000000..a59a993
--- /dev/null
+++ b/src/pixy/image/tiff/RationalField.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import pixy.string.StringUtils;
+
+/**
+ * TIFF Rational type field.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/06/2013
+ */
+public final class RationalField extends AbstractRationalField {
+
+	public RationalField(short tag, int[] data) {
+		super(tag, FieldType.RATIONAL, data);
+	}
+	
+	public String getDataAsString() {
+		return StringUtils.rationalArrayToString(data, true);
+	}
+}
diff --git a/src/pixy/image/tiff/SByteField.java b/src/pixy/image/tiff/SByteField.java
new file mode 100644
index 0000000..ca22af3
--- /dev/null
+++ b/src/pixy/image/tiff/SByteField.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+/**
+ * TIFF SByte type field.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 02/24/2013
+ */
+public final class SByteField extends AbstractByteField {
+
+	public SByteField(short tag, byte[] data) {
+		super(tag, FieldType.SBYTE, data);
+	}	
+}
diff --git a/src/pixy/image/tiff/SLongField.java b/src/pixy/image/tiff/SLongField.java
new file mode 100644
index 0000000..513f32a
--- /dev/null
+++ b/src/pixy/image/tiff/SLongField.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import pixy.string.StringUtils;
+
+/**
+ * TIFF SLong type field.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 02/24/2013
+ */
+public final class SLongField extends AbstractLongField {
+
+	public SLongField(short tag, int[] data) {
+		super(tag, FieldType.SLONG, data);
+	}
+	
+	public String getDataAsString() {
+		return StringUtils.longArrayToString(data, 0, MAX_STRING_REPR_LEN, false);
+	}
+}
diff --git a/src/pixy/image/tiff/SRationalField.java b/src/pixy/image/tiff/SRationalField.java
new file mode 100644
index 0000000..38f2225
--- /dev/null
+++ b/src/pixy/image/tiff/SRationalField.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import pixy.string.StringUtils;
+
+/**
+ * TIFF SRational type field.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 02/24/2013
+ */
+public final class SRationalField extends AbstractRationalField {
+
+	public SRationalField(short tag, int[] data) {
+		super(tag, FieldType.SRATIONAL, data);
+	}
+	
+	public String getDataAsString() {
+		return StringUtils.rationalArrayToString(data, false);
+	}
+}
diff --git a/src/pixy/image/tiff/SShortField.java b/src/pixy/image/tiff/SShortField.java
new file mode 100644
index 0000000..3a8fb49
--- /dev/null
+++ b/src/pixy/image/tiff/SShortField.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import pixy.string.StringUtils;
+
+/**
+ * TIFF SShort type field.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 02/24/2013
+ */
+public final class SShortField extends AbstractShortField {
+
+	public SShortField(short tag, short[] data) {
+		super(tag, FieldType.SSHORT, data);	
+	}
+	
+	public int[] getDataAsLong() {
+		//
+		int[] temp = new int[data.length];
+		
+		for(int i=0; i<data.length; i++) {
+			temp[i] = data[i];
+		}
+		
+		return temp;
+	}
+	
+	public String getDataAsString() {
+		return StringUtils.shortArrayToString(data, 0, MAX_STRING_REPR_LEN, false);
+	}
+}
diff --git a/src/pixy/image/tiff/ShortField.java b/src/pixy/image/tiff/ShortField.java
new file mode 100644
index 0000000..1522b1d
--- /dev/null
+++ b/src/pixy/image/tiff/ShortField.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import pixy.string.StringUtils;
+
+/**
+ * TIFF Short type field.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/06/2013
+ */
+public final class ShortField extends AbstractShortField {
+
+	public ShortField(short tag, short[] data) {
+		super(tag, FieldType.SHORT, data);	
+	}
+	
+	public int[] getDataAsLong() {
+		//
+		int[] temp = new int[data.length];
+		
+		for(int i=0; i<data.length; i++) {
+			temp[i] = data[i]&0xffff;
+		}
+				
+		return temp;
+	}
+	
+	public String getDataAsString() {
+		return StringUtils.shortArrayToString(data, 0, MAX_STRING_REPR_LEN, true);
+	}
+}
diff --git a/src/pixy/image/tiff/TIFFImage.java b/src/pixy/image/tiff/TIFFImage.java
new file mode 100644
index 0000000..2349b39
--- /dev/null
+++ b/src/pixy/image/tiff/TIFFImage.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import pixy.io.RandomAccessInputStream;
+import pixy.io.RandomAccessOutputStream;
+import pixy.meta.tiff.TIFFMeta;
+
+/**
+ * TIFF image wrapper to manipulate pages and fields
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 05/23/2014
+ */
+public class TIFFImage implements Iterable<IFD> {
+	// Define fields
+	private int numOfPages;
+	private int workingPage;
+	private List<IFD> ifds;
+	private RandomAccessInputStream rin;
+
+	public TIFFImage(RandomAccessInputStream rin) throws IOException {
+		ifds = new ArrayList<IFD>();
+		this.rin = rin;
+		TIFFMeta.readIFDs(ifds, rin);
+		this.numOfPages = ifds.size();
+		this.workingPage = 0;
+	}
+	
+	public void addField(TiffField<?> field) {
+		ifds.get(workingPage).addField(field);
+	}
+	
+	public TiffField<?> getField(Tag tag) {
+		return ifds.get(workingPage).getField(tag);
+	}
+	
+	public List<IFD> getIFDs() {
+		return Collections.unmodifiableList(ifds);
+	}
+	
+	public RandomAccessInputStream getInputStream() {
+		return rin;
+	}
+	
+	public int getNumOfPages() {
+		return numOfPages;
+	}
+	
+	public TiffField<?> removeField(Tag tag) {
+		return ifds.get(workingPage).removeField(tag);
+	}
+	
+	public IFD removePage(int index) {
+		IFD removed = ifds.remove(index);
+		numOfPages--;
+		
+		return removed;
+	}
+	
+	public void setWorkingPage(int workingPage) {
+		if(workingPage >= 0 && workingPage < numOfPages)
+			this.workingPage = workingPage;
+		else
+			throw new IllegalArgumentException("Invalid page number: " + workingPage);
+	}
+	
+	public void write(RandomAccessOutputStream out) throws IOException {
+		// Reset pageNumber if we have more than 1 pages
+		if(numOfPages > 1) { 
+			for(int i = 0; i < ifds.size(); i++) {
+				ifds.get(i).removeField(TiffTag.PAGE_NUMBER);
+				ifds.get(i).addField(new ShortField(TiffTag.PAGE_NUMBER.getValue(), new short[]{(short)i, (short)(numOfPages - 1)}));
+			}
+		}
+		TIFFMeta.write(this, out);
+	}
+
+	public Iterator<IFD> iterator() {
+		return ifds.iterator();
+	}
+}
diff --git a/src/pixy/image/tiff/Tag.java b/src/pixy/image/tiff/Tag.java
new file mode 100644
index 0000000..c687c64
--- /dev/null
+++ b/src/pixy/image/tiff/Tag.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+/**
+ * Common interface for all TIFF related tag enumerations
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/28/2014
+ */
+public interface Tag {
+	public String getFieldAsString(Object value);
+	public FieldType getFieldType();
+	public String getName();
+	public short getValue();
+	public boolean isCritical();
+}
diff --git a/src/pixy/image/tiff/TiffField.java b/src/pixy/image/tiff/TiffField.java
new file mode 100644
index 0000000..54d8dc5
--- /dev/null
+++ b/src/pixy/image/tiff/TiffField.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import java.io.IOException;
+
+import pixy.io.RandomAccessOutputStream;
+import pixy.string.StringUtils;
+
+/**
+ * IFD (Image File Directory) field.
+ * <p>
+ * We could have used a TiffTag enum as the first parameter of the constructor, but this
+ * will not work with unknown tags of tag type TiffTag.UNKNOWN. In that case, we cannot
+ * use the tag values to sort the fields or as keys for a hash map as used by {@link IFD}.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/04/2013
+ */
+public abstract class TiffField<T> implements Comparable<TiffField<?>>{
+
+	private final short tag;
+	private final FieldType fieldType;
+	private final int length;
+	protected T data;
+	protected IFD parent;
+	protected static final int MAX_STRING_REPR_LEN = 10; // Default length for string representation
+		
+	protected int dataOffset;
+	
+	public TiffField(IFD parent, short tag, FieldType fieldType, int length) {
+		this(tag, fieldType, length);
+		this.parent = parent;
+	}
+	
+	public TiffField(short tag, FieldType fieldType, int length) {
+		this.tag = tag;
+		this.fieldType = fieldType;
+		this.length = length;
+	}
+	
+	public int compareTo(TiffField<?> that) {
+		return (this.tag&0xffff) - (that.tag&0xffff);
+    }
+	
+	public T getData() {
+		return data;
+	}
+	
+	public IFD getParent() {
+		return new IFD(parent);
+	}
+	
+	/** Return an integer array representing TIFF long field */
+	public int[] getDataAsLong() { 
+		throw new UnsupportedOperationException("getDataAsLong() method is only supported by"
+				+ " short, long, and rational data types");
+	}
+	
+	/**
+	 * @return a String representation of the field data
+	 */
+	public abstract String getDataAsString();
+	
+	public int getLength() {
+		return length;
+	}
+	
+	/**
+	 * Used to update field data when necessary.
+	 * <p>
+	 * This method should be called only after the field has been written to the underlying RandomOutputStream.
+	 * 
+	 * @return the stream position where actual data starts to write
+	 */
+	public int getDataOffset() {
+		return dataOffset;
+	}
+	
+	public short getTag() {
+		return tag;
+	}
+	
+	public FieldType getType() {
+		return this.fieldType;
+	}
+
+	@Override public String toString() {
+		short tag = this.getTag();
+		Tag tagEnum = TiffTag.fromShort(tag);
+		
+		if (tagEnum != TiffTag.UNKNOWN)
+			return tagEnum.toString();
+		return tagEnum.toString() + " [TiffTag value: "+ StringUtils.shortToHexStringMM(tag) + "]";
+	}
+	
+	public final int write(RandomAccessOutputStream os, int toOffset) throws IOException {
+		// Write the header first
+		os.writeShort(this.tag);
+		os.writeShort(getType().getValue());
+		os.writeInt(getLength());
+		// Then the actual data
+		return writeData(os, toOffset);
+	}
+	
+	protected abstract int writeData(RandomAccessOutputStream os, int toOffset) throws IOException;
+}
diff --git a/src/pixy/image/tiff/TiffFieldEnum.java b/src/pixy/image/tiff/TiffFieldEnum.java
new file mode 100644
index 0000000..53eed5c
--- /dev/null
+++ b/src/pixy/image/tiff/TiffFieldEnum.java
@@ -0,0 +1,183 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * TiffFieldEnum.java
+ * <p>
+ * This class provides a central place for all the TIFF fields related enumerations
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 06/26/2014
+ */
+public class TiffFieldEnum {
+	
+	public enum PhotoMetric {
+		// Baseline
+		WHITE_IS_ZERO("WhiteIsZero (for bilevel and grayscale images)", 0),
+		BLACK_IS_ZERO("BlackIsZero (for bilevel and grayscale images)", 1),
+		RGB("RGB, value of (0,0,0) represents black, and (255,255,255) represents white, assuming 8-bit components", 2),
+		PALETTE_COLOR("Palette color, a color is described with a single component", 3),
+		TRANSPARENCY_MASK("Transparency mask, the image is used to define an irregularly shaped region of another image in the same TIFF file. SamplesPerPixel and BitsPerSample must be 1. PackBits compression is recommended", 4),
+		// Extension
+		SEPARATED("Separated, usually CMYK", 5),
+		YCbCr("YCbCr", 6),
+		CIE_LAB("CIE L*a*b*", 8),
+		ICC_LAB("ICC L*a*b*", 9),
+		ITU_LAB("ITU L*a*b*", 10),
+		CFA("CFA (Color Filter Array)", 32803),
+		LINEAR__RAW("LinearRaw", 34892),
+		
+		UNKNOWN("Unknown", 9999);
+		 
+		private PhotoMetric(String description, int value) {
+			this.description = description;
+			this.value = value;
+		}
+		
+		public String getDescription() {
+			return description;
+		}
+		
+		public int getValue() {
+			return value;
+		}
+		
+		@Override
+	    public String toString() {
+			return description;
+		}
+		
+		public static PhotoMetric fromValue(int value) {
+	       	PhotoMetric photoMetric = typeMap.get(value);
+	    	if (photoMetric == null)
+	    	   return UNKNOWN;
+	      	return photoMetric;
+	    }
+	    
+	    private static final Map<Integer, PhotoMetric> typeMap = new HashMap<Integer, PhotoMetric>();
+	       
+	    static
+	    {
+	      for(PhotoMetric photoMetric : values())
+	    	  typeMap.put(photoMetric.getValue(), photoMetric);
+	    } 
+
+		private String description;
+		private int value;
+	}
+	
+	public enum Compression {
+		//
+		NONE("No Compression", 1),
+		CCITTRLE("CCITT modified Huffman RLE", 2),
+		CCITTFAX3("CCITT Group 3 fax encoding", 3),
+		CCITTFAX4("CCITT Group 4 fax encoding", 4),
+		LZW("LZW", 5),
+		OLD_JPG("JPEG ('old-style' JPEG)", 6),
+	    JPG("JPEG ('new-style' JPEG technote #2)", 7),
+	    DEFLATE_ADOBE("Deflate ('Adobe-style')", 8),
+	    JBIG_ON_BW("JBIG on black and white", 9),
+		JBIG_ON_COLOR("JBIG on color", 10),
+		PACKBITS("PackBits compression, aka Macintosh RLE", 32773),
+		DEFLATE("Deflate", 32946),	
+		
+		UNKNOWN("Unknown", 9999);
+		
+		private Compression(String description, int value) {
+			this.description = description;
+			this.value = value;
+		}
+		
+		public String getDescription() {
+			return description;
+		}
+		
+		public int getValue() {
+			return value;
+		}
+		
+		@Override
+	    public String toString() {
+			return description;
+		}
+		
+		public static Compression fromValue(int value) {
+	       	Compression compression = typeMap.get(value);
+	    	if (compression == null)
+	    	   return UNKNOWN;
+	      	return compression;
+	    }
+	    
+	    private static final Map<Integer, Compression> typeMap = new HashMap<Integer, Compression>();
+	       
+	    static
+	    {
+	      for(Compression compression : values())
+	    	  typeMap.put(compression.getValue(), compression);
+	    } 
+
+		private String description;
+		private int value;
+	}
+	
+	public enum PlanarConfiguration {
+		CONTIGUOUS("Chunky format (The component values for each pixel are stored contiguously)", 1),
+		SEPARATE("Planar format (The components are stored in separate component planes)", 2),
+		UNKNOWN("Unknown", 9999);
+		
+		private PlanarConfiguration(String description, int value) {
+			this.description = description;
+			this.value = value;
+		}
+		
+		public String getDescription() {
+			return description;
+		}
+		
+		public int getValue() {
+			return value;
+		}
+		
+		@Override
+	    public String toString() {
+			return description;
+		}
+		
+		public static PlanarConfiguration fromValue(int value) {
+	       	PlanarConfiguration planarConfiguration = typeMap.get(value);
+	    	if (planarConfiguration == null)
+	    	   return UNKNOWN;
+	      	return planarConfiguration;
+	    }
+	    
+	    private static final Map<Integer, PlanarConfiguration> typeMap = new HashMap<Integer, PlanarConfiguration>();
+	       
+	    static
+	    {
+	      for(PlanarConfiguration planarConfiguration : values())
+	    	  typeMap.put(planarConfiguration.getValue(), planarConfiguration);
+	    } 
+		
+		private final String description;
+		private final int value;
+	}
+	
+	private TiffFieldEnum() {}	
+}
diff --git a/src/pixy/image/tiff/TiffTag.java b/src/pixy/image/tiff/TiffTag.java
new file mode 100644
index 0000000..3c1c7c0
--- /dev/null
+++ b/src/pixy/image/tiff/TiffTag.java
@@ -0,0 +1,997 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import java.io.UnsupportedEncodingException;
+import java.text.DecimalFormat;
+import java.util.HashMap;
+import java.util.Map;
+
+import pixy.string.StringUtils;
+
+/**
+ * TiffField tag enumeration.
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/04/2013
+ */
+public enum TiffTag implements Tag {	
+	// Definition includes all baseline and extended tags.	
+	NEW_SUBFILE_TYPE("NewSubfileType", (short)0x00FE, Attribute.BASELINE) {
+		public String getFieldAsString(Object value) {
+			//
+			int intValue = ((int[])value)[0];
+			String description = "Warning: unknown new subfile type value: " + value;
+			
+			switch(intValue) {
+				case 0: description = "Default value 0"; break; 
+				case 1:	description = "Reduced-resolution image data"; break;
+				case 2: description = "A single page of a multi-page image";	break;
+				case 4: description = "A transparency mask for another image";	break;
+			}
+			
+			return description;
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.LONG;
+		}
+	},
+	
+	SUBFILE_TYPE("SubfileType", (short)0x00FF, Attribute.BASELINE) {
+		public String getFieldAsString(Object value) {
+			//
+			int intValue = ((int[])value)[0];
+			String description = "Unknown subfile type value: " + value;
+			
+			switch(intValue) {
+				case 0: description = "Default value 0"; break;
+				case 1:	description = "Full-resolution image data"; break;
+				case 2: description = "Reduced-resolution image data"; break;
+				case 3: description = "A single page of a multi-page image";	break;
+			}
+			
+			return description;
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	IMAGE_WIDTH("ImageWidth", (short)0x0100, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.LONG; // Or SHORT
+		}
+	},
+	
+	IMAGE_LENGTH("ImageLength", (short)0x0101, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.LONG; // Or SHORT
+		}
+	},
+	
+	BITS_PER_SAMPLE("BitsPerSample", (short)0x0102, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	COMPRESSION("Compression", (short)0x0103, Attribute.BASELINE) {
+		public String getFieldAsString(Object value) {
+			return TiffFieldEnum.Compression.fromValue(((int[])value)[0]).getDescription();
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	PHOTOMETRIC_INTERPRETATION("PhotometricInterpretation", (short)0x0106, Attribute.BASELINE) {
+		public String getFieldAsString(Object value) {
+			return TiffFieldEnum.PhotoMetric.fromValue(((int[])value)[0]).getDescription();
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	THRESHOLDING("Thresholding", (short)0x0107, Attribute.BASELINE) {
+		public String getFieldAsString(Object value) {
+			//
+			int intValue = ((int[])value)[0];
+			String description = "Unknown thresholding value: " + value;
+			
+			switch(intValue) {
+				case 1:	description = "No dithering or halftoning has been applied to the image data"; break;
+				case 2: description = "An ordered dither or halftone technique has been applied to the image data";	break;
+				case 3: description = "A randomized process such as error diffusion has been applied to the image data";	break;
+			}
+			
+			return description;
+		}		
+	},
+	
+	CELL_WIDTH("CellWidth", (short)0x0108, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	CELL_LENGTH("CellLength", (short)0x0109, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	FILL_ORDER("FillOrder", (short)0x010A, Attribute.BASELINE) {
+		public String getFieldAsString(Object value) {
+			// We only has two values for this tag, so we use an array instead of a map
+			String[] values = {"Msb2Lsb", "Lsb2Msb"};
+			int intValue = ((int[])value)[0];
+			if(intValue != 1 && intValue != 2) 
+				return "Warning: unknown fill order value: " + intValue;
+			
+			return values[intValue-1]; 
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	DOCUMENT_NAME("DocumentName", (short)0x010D, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.ASCII;
+		}
+	},
+	
+	IMAGE_DESCRIPTION("ImageDescription", (short)0x010E, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.ASCII;
+		}
+		public boolean isCritical() {
+	    	return false;
+	    }
+	},
+	
+	MAKE("Make", (short)0x010F, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.ASCII;
+		}
+		public boolean isCritical() {
+	    	return false;
+	    }
+	},
+	
+	MODEL("Model", (short)0x0110, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.ASCII;
+		}
+	},
+	
+	STRIP_OFFSETS("StripOffsets", (short)0x0111, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.LONG; // Or SHORT
+		}
+	},
+	
+	ORIENTATION("Orientation", (short)0x0112, Attribute.BASELINE) {
+		public String getFieldAsString(Object value) {
+			String[] values = {"TopLeft", "TopRight", "BottomRight", "BottomLeft", "LeftTop",
+								"RightTop",	"RightBottom", "LeftBottom"};
+			int intValue = ((int[])value)[0];
+			if(intValue >= 1 && intValue <= 8)
+				return values[intValue - 1];
+			
+			return "Warning: unknown orientation value: " + intValue;
+		}
+	},
+	
+	SAMPLES_PER_PIXEL("SamplesPerPixel", (short)0x0115, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	ROWS_PER_STRIP("RowsPerStrip", (short)0x0116, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.LONG; // Or SHORT
+		}
+	},
+	
+	STRIP_BYTE_COUNTS("StripByteCounts", (short)0x0117, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.LONG; // Or SHORT
+		}
+	},
+	
+	MIN_SAMPLE_VALUE("MinSampleValue", (short)0x0118, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	MAX_SAMPLE_VALUE("MaxSampleValue", (short)0x0119, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	X_RESOLUTION("XResolution", (short)0x011A, Attribute.BASELINE) {
+		public String getFieldAsString(Object value) {
+			int[] intValues = (int[])value;
+			if(intValues.length != 2)
+				throw new IllegalArgumentException("Wrong number of XResolution data number: " + intValues.length);
+			//formatting numbers up to 3 decimal places in Java
+	        DecimalFormat df = new DecimalFormat("#,###,###.##");
+	        return StringUtils.rationalToString(df, true, intValues);	
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.RATIONAL;
+		}
+	},
+	
+	Y_RESOLUTION("YResolution", (short)0x011B, Attribute.BASELINE) {
+		public String getFieldAsString(Object value) {
+			int[] intValues = (int[])value;
+			if(intValues.length != 2)
+				throw new IllegalArgumentException("Wrong number of YResolution data number: " + intValues.length);
+			//formatting numbers up to 3 decimal places in Java
+	        DecimalFormat df = new DecimalFormat("#,###,###.##");
+	        return StringUtils.rationalToString(df, true, intValues);	
+		}
+		public FieldType getFieldType() {
+			return FieldType.RATIONAL;
+		}
+	},
+	
+	PLANAR_CONFIGURATTION("PlanarConfiguration", (short)0x011C, Attribute.BASELINE) {
+		public String getFieldAsString(Object value) {
+			return TiffFieldEnum.PlanarConfiguration.fromValue(((int[])value)[0]).getDescription();
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	PAGE_NAME("	PageName", (short)0x011D, Attribute.EXTENDED),
+	X_POSITION("XPosition", (short)0x011E, Attribute.EXTENDED),
+	Y_POSITION("YPosition", (short)0x011F, Attribute.EXTENDED),
+	
+	FREE_OFFSETS("FreeOffsets", (short)0x0120, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.LONG;
+		}
+	},
+	
+	FREE_BYTE_COUNTS("FreeByteCounts", (short)0x0121, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.LONG;
+		}
+	},
+	
+	/**
+	 *  The precision of the information contained in the GrayResponseCurve.
+	 *  Because optical density is specified in terms of fractional numbers,
+	 *  this field is necessary to interpret the stored integer information.
+	 *  For example, if GrayScaleResponseUnits is set to 4 (ten-thousandths 
+	 *  of a unit), and a GrayScaleResponseCurve number for gray level 4 is
+	 *  3455, then the resulting actual value is 0.3455. 
+	 */
+	GRAY_RESPONSE_UNIT("GrayResponseUnit", (short)0x0122, Attribute.BASELINE) {
+		public String getFieldAsString(Object value) {
+			//
+			String[] values = {"Number represents tenths of a unit", "Number represents hundredths of a unit",
+					"Number represents thousandths of a unit", "Number represents ten-thousandths of a unit",
+					"Number represents hundred-thousandths of a unit"};
+			// Valid values are from 1 to 5
+			int intValue = ((int[])value)[0];
+			if(intValue >= 1 && intValue <= 5)
+				return values[intValue - 1];
+			
+			return "Warning: unknown resolution unit value: " + intValue;
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	GRAY_RESPONSE_CURVE("GrayResponseCurve", (short)0x0123, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	T4_OPTIONS("T4Options", (short)0x0124, Attribute.EXTENDED) {
+		public String getFieldAsString(Object value) {
+			//
+			int intValue = ((int[])value)[0];
+			String description = "Warning: unknown T4 options value: " + intValue;
+			
+			switch(intValue) {
+				case 0: description = "Basic 1-dimensional coding"; break; 
+				case 1:	description = "2-dimensional coding"; break;
+				case 2: description = "Uncompressed mode";	break;
+				case 4: description = "Fill bits have been added as necessary before EOL codes such that EOL always ends on a byte boundary";	break;
+			}
+			
+			return description;
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	}, 
+	
+	T6_OPTIONS("T6Options", (short)0x0125, Attribute.EXTENDED) {
+		public String getFieldAsString(Object value) {
+			//
+			int intValue = ((int[])value)[0];
+			String description = "Warning: unknown T6 options value: " + intValue;
+			
+			switch(intValue) {
+				case 2: description = "Uncompressed mode"; break;
+				case 0: description = "Default value 0";
+			}			
+			
+			return description;
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	RESOLUTION_UNIT("ResolutionUnit", (short)0x0128, Attribute.BASELINE) {
+		public String getFieldAsString(Object value) {
+			// We only has three values for this tag, so we use an array instead of a map
+			String[] values = {"No absolute unit of measurement (Used for images that may have a non-square aspect ratio, but no meaningful absolute dimensions)", 
+					"Inch", "Centimeter"};
+			int intValue = ((int[])value)[0];
+			if(intValue != 1 && intValue != 2 && intValue != 3) 
+				return "Warning: unknown resolution unit value: " + intValue;
+			
+			return values[intValue-1]; 
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	PAGE_NUMBER("PageNumber", (short)0x0129, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	TRANSFER_FUNCTION("TransferFunction", (short)0x012D, Attribute.EXTENDED),
+	
+	SOFTWARE("Software", (short)0x0131, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.ASCII;
+		}
+		public boolean isCritical() {
+	    	return false;
+	    }
+	},
+	
+	DATETIME("DateTime", (short)0x0132, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.ASCII;
+		}
+	},
+	
+	ARTIST("Artist", (short)0x013B, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.ASCII;
+		}
+	},
+	
+	HOST_COMPUTER("HostComputer", (short)0x013C, Attribute.BASELINE),
+	
+	PREDICTOR("Predictor", (short)0x013D, Attribute.EXTENDED) {
+		public String getFieldAsString(Object value) {
+			//
+			int intValue = ((int[])value)[0];
+			String description = "Unknown predictor value: " + intValue;
+			
+			switch(intValue) {
+				case 1:	description = "No prediction scheme used before coding"; break;
+				case 2: description = "Horizontal differencing"; break;
+				case 3: description = "Floating point horizontal differencing";	break;
+			}
+			
+			return description;
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	WHITE_POINT("WhitePoint", (short)0x013E, Attribute.EXTENDED),
+	PRIMARY_CHROMATICITIES("PrimaryChromaticities", (short)0x013F, Attribute.EXTENDED),
+	
+	COLORMAP("ColorMap", (short)0x0140, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	HALTONE_HINTS("HalftoneHints", (short)0x0141, Attribute.EXTENDED),
+	
+	TILE_WIDTH("TileWidth", (short)0x0142, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.LONG; // Or SHORT
+		}
+	},
+	
+	TILE_LENGTH("TileLength", (short)0x0143, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.LONG; // Or SHORT
+		}
+	},
+	
+	TILE_OFFSETS("TileOffsets", (short)0x0144, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.LONG;
+		}
+	},
+	
+	TILE_BYTE_COUNTS("TileByteCounts", (short)0x0145, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.LONG; // Or SHORT
+		}
+	},
+	
+	BAD_FAX_LINES("BadFaxLines", (short)0x0146, Attribute.EXTENDED),
+	CLEAN_FAX_DATA("CleanFaxData", (short)0x0147, Attribute.EXTENDED),
+	CONSECUTIVE_BAD_FAX_LINES("ConsecutiveBadFaxLines", (short)0x0148, Attribute.EXTENDED),
+	
+	SUB_IFDS("SubIFDs", (short)0x014A, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.LONG; // Or IFD
+		}
+	},
+	
+	INK_SET("InkSet", (short)0x014C, Attribute.EXTENDED) {
+		public String getFieldAsString(Object value) {
+			//
+			int intValue = ((int[])value)[0];
+			String description = "Warning: unknown InkSet value: " + intValue;
+			
+			switch(intValue) {
+				case 1:	description = "CMYK"; break;
+				case 2: description = "Not CMYK"; break;
+			}
+			
+			return description;
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	INK_NAMES("InkNames", (short)0x014D, Attribute.EXTENDED),
+	NUMBER_OF_INKS("NumberOfInks", (short)0x014E, Attribute.EXTENDED),
+	DOT_RANGE("DotRange", (short)0x0150, Attribute.EXTENDED),
+	TARGET_PRINTER("TargetPrinter", (short)0x0151, Attribute.EXTENDED),
+	
+	EXTRA_SAMPLES("ExtraSamples", (short)0x0152, Attribute.BASELINE) {
+		public String getFieldAsString(Object value) {
+			// We only has three values for this tag, so we use an array instead of a map
+			String[] values = {"Unspecified data", "Associated alpha data (with pre-multiplied color)",
+					"Unassociated alpha data"};
+			int intValue = ((int[])value)[0];
+			if(intValue >= 0 && intValue <= 2)
+				return values[intValue]; 
+			
+			return "Warning: unknown extra samples value: " + intValue;
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	SAMPLE_FORMAT("SampleFormat", (short)0x0153, Attribute.EXTENDED) {
+		public String getFieldAsString(Object value) {
+			String[] values = {"Unsigned integer data", "Two's complement signed integer data",
+					"IEEE floating point data",	"Undefined data format", "Complex integer data",
+					"Complex IEED floating point data"};
+			int intValue = ((int[])value)[0];
+			if(intValue >= 1 && intValue <= 6)
+				return values[intValue - 1]; 
+			
+			return "Warning: unknown sample format value: " + intValue;
+		}
+	},
+	
+	S_MIN_SAMPLE_VALUE("SMinSampleValue", (short)0x0154, Attribute.EXTENDED),
+	S_MAX_SAMPLE_VALUE("SMaxSampleValue", (short)0x0155, Attribute.EXTENDED),
+	TRANSFER_RANGE("TransferRange", (short)0x0156, Attribute.EXTENDED),
+	CLIP_PATH("ClipPath", (short)0x0157, Attribute.EXTENDED),
+	X_CLIP_PATH_UNITS("XClipPathUnits", (short)0x0158, Attribute.EXTENDED),
+	Y_CLIP_PATH_UNITS("YClipPathUnits", (short)0x0159, Attribute.EXTENDED),
+	
+	INDEXED("Indexed", (short)0x015A, Attribute.EXTENDED) {
+		public String getFieldAsString(Object value) {
+			//
+			int intValue = ((int[])value)[0];
+			String description = "Warning: unknown Indexed value: " + intValue;
+			
+			switch(intValue) {
+				case 0:	description = "Not indexde"; break;
+				case 1: description = "Indexed"; break;
+			}
+			
+			return description;
+		} 
+	},
+	
+	JPEG_TABLES("JPEGTables - optional, for new-style JPEG compression", (short)0x015B, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.UNDEFINED;
+		}
+	},
+	
+	OPI_PROXY("OPIProxy", (short)0x015F, Attribute.EXTENDED),
+	
+	GLOBAL_PARAMETERS_IFD("GlobalParametersIFD", (short)0x0190, Attribute.EXTENDED),
+	
+	PROFILE_TYPE("ProfileType", (short)0x0191, Attribute.EXTENDED) {
+		public String getFieldAsString(Object value) {
+			//
+			int intValue = ((int[])value)[0];
+			String description = "Warning: unknown profile type value: " + intValue;
+			
+			switch(intValue) {
+				case 0:	description = "Unspecified"; break;
+				case 1: description = "Group 3 fax"; break;
+			}
+			
+			return description;
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	FAX_PROFILE("FaxProfile", (short)0x0192, Attribute.EXTENDED) {
+		public String getFieldAsString(Object value) {
+			String[] values = {"Does not conform to a profile defined for TIFF for facsimile", "Minimal black & white lossless, Profile S",
+					"Extended black & white lossless, Profile F",	"Lossless JBIG black & white, Profile J", "Lossy color and grayscale, Profile C",
+					"Lossless color and grayscale, Profile L", "Mixed Raster Content, Profile M"};
+			int intValue = ((int[])value)[0];
+			if(intValue >= 0 && intValue <= 6)
+				return values[intValue]; 
+			
+			return "Warning: unknown fax profile value: " + intValue;
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	CODING_METHODS("CodingMethods", (short)0x0193, Attribute.EXTENDED) {
+		public String getFieldAsString(Object value) {
+			//
+			int intValue = ((int[])value)[0];
+			String description = "Unknown coding method value: " + intValue;
+			
+			switch(intValue) {
+				case 1:	description  = "Unspecified compression"; break;
+				case 2: description  = "1-dimensional coding, ITU-T Rec. T.4 (MH - Modified Huffman)"; break;
+				case 4: description  = "2-dimensional coding, ITU-T Rec. T.4 (MR - Modified Read)"; break;
+				case 8: description  = "2-dimensional coding, ITU-T Rec. T.6 (MMR - Modified MR)"; break;
+				case 16: description = "ITU-T Rec. T.82 coding, using ITU-T Rec. T.85 (JBIG)"; break;
+				case 32: description = "ITU-T Rec. T.81 (Baseline JPEG)"; break;
+				case 64: description = "ITU-T Rec. T.82 coding, using ITU-T Rec. T.43 (JBIG color)"; break;
+			}
+			
+			return description;
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},				
+	
+	VERSION_YEAR("VersionYear", (short)0x0194, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.BYTE;
+		}
+	},
+	
+	MODE_NUMBER("ModeNumber", (short)0x0195, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.BYTE;
+		}
+	},
+	
+	DECODE("Decode", (short)0x01B1, Attribute.EXTENDED),
+	DEFAULT_IMAGE_COLOR("DefaultImageColor", (short)0x01B2, Attribute.EXTENDED),
+	
+	JPEG_PROC("JPEGProc", (short)0x0200, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	JPEG_INTERCHANGE_FORMAT("JPEGInterchangeFormat/JpegIFOffset", (short)0x0201, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.LONG;
+		}
+	},
+	
+	JPEG_INTERCHANGE_FORMAT_LENGTH("JPEGInterchangeFormatLength/JpegIFByteCount", (short)0x0202, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.LONG;
+		}
+	},
+	
+	JPEG_RESTART_INTERVAL("JPEGRestartInterval", (short)0x0203, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	JPEG_LOSSLESS_PREDICTORS("JPEGLosslessPredictors", (short)0x0205, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	JPEG_POINT_TRANSFORMS("JPEGPointTransforms", (short)0x0206, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	JPEG_Q_TABLES("JPEGQTables", (short)0x0207, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.LONG;
+		}
+	},
+	
+	JPEG_DC_TABLES("JPEGDCTables", (short)0x0208, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.LONG;
+		}
+	},
+	
+	JPEG_AC_TABLES("JPEGACTables", (short)0x0209, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.LONG;
+		}
+	},
+	
+	YCbCr_COEFFICIENTS("YCbCrCoefficients", (short)0x0211, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.RATIONAL;
+		}
+	},
+	
+	YCbCr_SUB_SAMPLING("YCbCrSubSampling", (short)0x0212, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	YCbCr_POSITIONING("YCbCrPositioning", (short)0x0213, Attribute.EXTENDED) {
+		public String getFieldAsString(Object value) {
+			//
+			int intValue = ((int[])value)[0];
+			String description = "Warning: unknown YCbCr positioning value: " + intValue;
+			
+			switch(intValue) {
+				case 1:	description = "Centered"; break;
+				case 2: description = "Cosited"; break;
+			}
+			
+			return description;
+		}
+		
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	
+	REFERENCE_BLACK_WHITE("ReferenceBlackWhite", (short)0x0214, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.RATIONAL;
+		}
+	},
+	
+	STRIP_ROW_COUNTS("StripRowCounts", (short)0x022F, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.LONG;
+		}
+	},
+	
+	XMP("XMP", (short)0x02BC, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.BYTE;
+		}
+	},
+	
+	RATING("Rating", (short)0x4746, Attribute.PRIVATE),
+	RATING_PERCENT("RatingPercent", (short)0x4749, Attribute.PRIVATE),
+	
+	IMAGE_ID("ImageID", (short)0x800D, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.ASCII;
+		}
+	},
+	
+	MATTEING("Matteing", (short)0x80e3, Attribute.PRIVATE),
+	
+	COPYRIGHT("Copyright", (short)0x8298, Attribute.BASELINE) {
+		public FieldType getFieldType() {
+			return FieldType.ASCII;
+		}
+	},
+	
+	// (International Press Telecommunications Council) metadata.
+	IPTC("RichTiffIPTC", (short)0x83BB, Attribute.PRIVATE) {
+		public FieldType getFieldType() {
+			return FieldType.UNDEFINED; // Or BYTE
+		}
+	},
+	
+	IT8_SITE("IT8Site", (short)0x84e0, Attribute.PRIVATE),
+    IT8_COLOR_SEQUENCE("IT8ColorSequence", (short)0x84e1, Attribute.PRIVATE),
+    IT8_HEADER("IT8Header", (short)0x84e2, Attribute.PRIVATE),
+    IT8_RASTER_PADDING("IT8RasterPadding", (short)0x84e3, Attribute.PRIVATE),
+    IT8_BITS_PER_RUN_LENGTH("IT8BitsPerRunLength", (short)0x84e4, Attribute.PRIVATE),
+    IT8_BITS_PER_EXTENDED_RUN_LENGTH("IT8BitsPerExtendedRunLength", (short)0x84e5, Attribute.PRIVATE),
+    IT8_COLOR_TABLE("IT8ColorTable", (short)0x84e6, Attribute.PRIVATE),
+    IT8_IMAGE_COLOR_INDICATOR("IT8ImageColorIndicator", (short)0x84e7, Attribute.PRIVATE),
+    IT8_BKG_COLOR_INDICATOR("IT8BkgColorIndicator", (short)0x84e8, Attribute.PRIVATE),
+    IT8_IMAGE_COLOR_VALUE("IT8ImageColorValue", (short)0x84e9, Attribute.PRIVATE),
+    IT8_BKG_COLOR_VALUE("IT8BkgColorValue", (short)0x84ea, Attribute.PRIVATE),
+    IT8_PIXEL_INTENSITY_RANGE("IT8PixelIntensityRange", (short)0x84eb, Attribute.PRIVATE),
+    IT8_TRANSPARENCY_INDICATOR("IT8TransparencyIndicator", (short)0x84ec, Attribute.PRIVATE),
+    IT8_COLOR_CHARACTERIZATION("IT8ColorCharacterization", (short)0x84ed, Attribute.PRIVATE),
+    IT8_HC_USAGE("IT8HCUsage", (short)0x84ee, Attribute.PRIVATE),
+    
+    IPTC2("RichTiffIPTC", (short)0x8568, Attribute.PRIVATE),
+    
+    FRAME_COUNT("FrameCount", (short)0x85b8, Attribute.PRIVATE),
+   
+    // Photoshop image resources
+    PHOTOSHOP("Photoshop", (short)0x8649, Attribute.PRIVATE) {
+		public FieldType getFieldType() {
+			return FieldType.BYTE;
+		}
+	},
+    
+	// The following tag is for ExifSubIFD
+	EXIF_SUB_IFD("ExifSubIFD", (short)0x8769, Attribute.PRIVATE) {
+		public FieldType getFieldType() {
+			return FieldType.LONG;
+		}
+	},
+	
+	IMAGE_LAYER("ImageLayer", (short)0x87ac, Attribute.EXTENDED) {
+		public FieldType getFieldType() {
+			return FieldType.LONG; // Or SHORT
+		}
+	},
+	
+	ICC_PROFILE("ICC Profile", (short)0x8773, Attribute.PRIVATE) {
+		public FieldType getFieldType() {
+			return FieldType.UNDEFINED;
+		}
+	},
+	
+	// The following tag is for GPSSubIFD
+	GPS_SUB_IFD("GPSSubIFD", (short)0x8825, Attribute.PRIVATE) {
+		public FieldType getFieldType() {
+			return FieldType.LONG;
+		}
+	},
+	
+	DATE_TIME_ORIGINAL("DateTime Original", (short)0x9003, Attribute.UNKNOWN),
+	
+	/* Photoshop-specific TIFF tag. Starts with a null-terminated character
+	 * string of "Adobe Photoshop Document Data Block"
+	 */
+	IMAGE_SOURCE_DATA("ImageSourceData", (short)0x935C, Attribute.PRIVATE),
+	
+	WINDOWS_XP_TITLE("WindowsXPTitle", (short) 0x9c9b, Attribute.PRIVATE) {
+		public String getFieldAsString(Object value) {
+			//
+			byte[] byteValue = (byte[]) value;
+			String description = "";
+			try {
+				description = new String(byteValue, "UTF-16LE");
+			} catch (UnsupportedEncodingException e) {
+				e.printStackTrace();
+			}
+
+			return description.trim();
+		}
+		
+		public boolean isCritical() {
+	    	return false;
+	    }
+	},
+	
+	WINDOWS_XP_COMMENT("WindowsXPComment", (short)0x9c9c, Attribute.PRIVATE) {
+		public String getFieldAsString(Object value) {
+			//
+			byte[] byteValue = (byte[])value;
+			String description = "";
+			try {
+				description = new String(byteValue, "UTF-16LE");
+			} catch (UnsupportedEncodingException e) {
+				e.printStackTrace();
+			}
+			
+			return description.trim();
+		}
+		
+		public boolean isCritical() {
+	    	return false;
+	    }
+	},
+	
+	WINDOWS_XP_AUTHOR("WindowsXPAuthor", (short)0x9c9d, Attribute.PRIVATE) {
+		public String getFieldAsString(Object value) {
+			//
+			byte[] byteValue = (byte[])value;
+			String description = "";
+			try {
+				description = new String(byteValue, "UTF-16LE");
+			} catch (UnsupportedEncodingException e) {
+				e.printStackTrace();
+			}
+			
+			return description.trim();
+		}
+		
+		public boolean isCritical() {
+	    	return false;
+	    }
+	},
+	
+	WINDOWS_XP_KEYWORDS("WindowsXPKeywords", (short)0x9c9e, Attribute.PRIVATE){
+		public String getFieldAsString(Object value) {
+			//
+			byte[] byteValue = (byte[])value;
+			String description = "";
+			try {
+				description = new String(byteValue, "UTF-16LE");
+			} catch (UnsupportedEncodingException e) {
+				e.printStackTrace();
+			}
+			
+			return description.trim();
+		}
+		
+		public boolean isCritical() {
+	    	return false;
+	    }
+	},
+	
+	WINDOWS_XP_SUBJECT("WindowsXPSubject", (short) 0x9c9f, Attribute.PRIVATE) {
+		public String getFieldAsString(Object value) {
+			//
+			byte[] byteValue = (byte[]) value;
+			String description = "";
+			try {
+				description = new String(byteValue, "UTF-16LE");
+			} catch (UnsupportedEncodingException e) {
+				e.printStackTrace();
+			}
+
+			return description.trim();
+		}
+		
+		public boolean isCritical() {
+	    	return false;
+	    }
+	},
+	
+	// Ranking unknown tag the least significant.
+	UNKNOWN("Unknown", (short)0xffff, Attribute.UNKNOWN); 
+	
+	public enum Attribute {
+		BASELINE, EXTENDED, PRIVATE, UNKNOWN;
+		
+		@Override public String toString() {
+			return StringUtils.capitalizeFully(name());
+		}
+	} 
+	
+	private static final Map<Short, TiffTag> tagMap = new HashMap<Short, TiffTag>();
+	
+	static
+    {
+      for(TiffTag tiffTag : values()) {
+           tagMap.put(tiffTag.getValue(), tiffTag);
+      }
+    }	
+	
+	public static Tag fromShort(short value) {
+       	TiffTag tiffTag = tagMap.get(value);
+    	if (tiffTag == null)
+    	   return UNKNOWN;
+       	return tiffTag;
+    }
+	
+	private final String name;
+	
+	private final short value;
+	
+    private final Attribute attribute;
+    
+    private TiffTag(String name, short value, Attribute attribute)
+	{
+		this.name = name;
+		this.value = value;
+		this.attribute = attribute;
+	}
+       
+    public Attribute getAttribute() {
+		return attribute;
+	}    
+   
+    /**
+     * Intended to be overridden by certain tags to provide meaningful string
+     * representation of the field value such as compression, photo metric interpretation etc.
+     * 
+	 * @param value field value to be mapped to a string
+	 * @return a string representation of the field value or empty string if no meaningful string
+	 * 	representation exists.
+	 */
+    public String getFieldAsString(Object value) {
+    	return "";
+	}
+    
+ 	public FieldType getFieldType() {
+		return FieldType.UNKNOWN;
+	}
+    
+    public String getName()	{
+		return this.name;
+	}
+    
+    public short getValue()	{
+		return this.value;
+	}
+    
+    @Override
+    public String toString() {
+		if (this == UNKNOWN)
+			return name;
+		return name + " [Value: " + StringUtils.shortToHexStringMM(value) +"] (" + getAttribute() + ")";
+	}
+    
+    public boolean isCritical() {
+    	return true;
+    }
+}
diff --git a/src/pixy/image/tiff/UndefinedField.java b/src/pixy/image/tiff/UndefinedField.java
new file mode 100644
index 0000000..9ff0768
--- /dev/null
+++ b/src/pixy/image/tiff/UndefinedField.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.image.tiff;
+
+import java.io.IOException;
+
+import pixy.io.RandomAccessOutputStream;
+import pixy.string.StringUtils;
+
+/**
+ * TIFF Attribute.UNDEFINED type field.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 02/05/2013
+ */
+public final class UndefinedField extends TiffField<byte[]> {
+
+	public UndefinedField(short tag, byte[] data) {
+		super(tag, FieldType.UNDEFINED, data.length);
+		this.data = data;
+	}
+	
+	public byte[] getData() {
+		return data.clone();
+	}
+	
+	public String getDataAsString() {
+		return StringUtils.byteArrayToHexString(data, 0, MAX_STRING_REPR_LEN);
+	}
+	
+	protected int writeData(RandomAccessOutputStream os, int toOffset) throws IOException {
+	
+		if (data.length <= 4) {
+			dataOffset = (int)os.getStreamPointer();
+			byte[] tmp = new byte[4];
+			System.arraycopy(data, 0, tmp, 0, data.length);
+			os.write(tmp);
+		} else {
+			dataOffset = toOffset;
+			os.writeInt(toOffset);
+			os.seek(toOffset);
+			os.write(data);
+			toOffset += data.length;
+		}
+		
+		return toOffset;
+	}
+}
diff --git a/src/pixy/io/ByteOrder.java b/src/pixy/io/ByteOrder.java
new file mode 100644
index 0000000..c34b9ca
--- /dev/null
+++ b/src/pixy/io/ByteOrder.java
@@ -0,0 +1,6 @@
+package pixy.io;
+
+public enum ByteOrder {
+	BIG_ENDIAN,
+	LITTLE_ENDIAN;
+}
diff --git a/src/pixy/io/EndianAwareInputStream.java b/src/pixy/io/EndianAwareInputStream.java
new file mode 100644
index 0000000..e9c39f4
--- /dev/null
+++ b/src/pixy/io/EndianAwareInputStream.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.io;
+
+import java.io.DataInput;
+import java.io.DataInputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Endian-aware InputStream backed up by ReadStrategy
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 02/03/2014 
+ */ 
+public class EndianAwareInputStream extends InputStream implements DataInput {
+	
+    private InputStream src;
+    private ReadStrategy strategy = ReadStrategyMM.getInstance();
+    
+	public EndianAwareInputStream(InputStream is) {
+	      this.src = is;
+	}
+	
+	public int read() throws IOException {
+    	return src.read();
+    }
+
+	public boolean readBoolean() throws IOException {
+		int ch = this.read();
+		if (ch < 0)
+		    throw new EOFException();
+		return (ch != 0);
+	}
+
+	public byte readByte() throws IOException {
+	    int ch = this.read();
+		if (ch < 0)
+		   throw new EOFException();
+		return (byte)ch;
+	}
+	
+	public char readChar() throws IOException {
+		return (char)(readShort()&0xffff);
+	}
+	
+	public double readDouble() throws IOException {
+		return Double.longBitsToDouble(readLong());
+	}
+
+	public float readFloat() throws IOException {
+		return Float.intBitsToFloat(readInt());
+	}
+	
+	public void readFully(byte[] b) throws IOException {
+		readFully(b, 0, b.length);
+	}	
+	
+    public void readFully(byte[] b, int off, int len) throws IOException {
+		int n = 0;
+		do {
+			int count = src.read(b, off + n, len - n);
+		    if (count < 0)
+		        throw new EOFException();
+		    n += count;
+		} while (n < len);
+	}
+
+    public int readInt() throws IOException {
+		byte[] buf = new byte[4];
+        readFully(buf);
+    	return strategy.readInt(buf, 0);
+	}
+
+	@Deprecated
+	public String readLine() throws IOException {
+		throw new UnsupportedOperationException(
+			"readLine is not supported by RandomAccessInputStream."
+		);
+	}
+
+	public long readLong() throws IOException {
+		byte[] buf = new byte[8];
+        readFully(buf);
+    	return strategy.readLong(buf, 0);
+	}
+
+	public float readS15Fixed16Number() throws IOException {
+		byte[] buf = new byte[4];
+        readFully(buf);
+		return strategy.readS15Fixed16Number(buf, 0);
+	}
+
+	public short readShort() throws IOException {
+		byte[] buf = new byte[2];
+        readFully(buf);
+    	return strategy.readShort(buf, 0);
+	}
+
+	public float readU16Fixed16Number() throws IOException {
+		byte[] buf = new byte[4];
+        readFully(buf);
+		return strategy.readU16Fixed16Number(buf, 0);
+	}
+
+	public float readU8Fixed8Number() throws IOException {
+		byte[] buf = new byte[2];
+        readFully(buf);
+		return strategy.readU8Fixed8Number(buf, 0);
+	}
+
+	public int readUnsignedByte() throws IOException {
+		int ch = this.read();
+		if (ch < 0)
+		   throw new EOFException();
+	    return ch;
+	}
+	
+	public long readUnsignedInt() throws IOException {
+		return readInt()&0xffffffffL;
+	}
+	
+	public int readUnsignedShort() throws IOException {
+		return readShort()&0xffff;
+	}
+
+	/**
+	 *  Due to the current implementation, writeUTF and readUTF are the
+	 *  only methods which are machine or byte sequence independent as
+	 *  they are actually both Motorola byte sequence under the hood.
+	 *  
+	 *  Whereas the following static method is byte sequence dependent
+	 *  as it calls readUnsignedShort of RandomAccessInputStream.
+	 *  
+	 *  <code>DataInputStream.readUTF(this)</code>;
+	 */
+	public String readUTF() throws IOException {
+		return new DataInputStream(this).readUTF();	
+	}
+
+	public void setReadStrategy(ReadStrategy strategy) 
+	{
+		this.strategy = strategy;
+	}
+	
+	public int skipBytes(int n) throws IOException {
+		int bytes = src.read(new byte[n], 0, n);
+		/* return the actual number of bytes skipped */
+		return bytes;
+	}
+	
+	public void close() throws IOException {
+		src.close();
+	}
+}
diff --git a/src/pixy/io/EndianAwareOutputStream.java b/src/pixy/io/EndianAwareOutputStream.java
new file mode 100644
index 0000000..6693712
--- /dev/null
+++ b/src/pixy/io/EndianAwareOutputStream.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.io;
+
+import java.io.DataOutput;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Endian-aware OutputStream backed up by WriteStrategy
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 02/03/2014
+ */
+public class EndianAwareOutputStream extends OutputStream implements DataOutput {
+
+	private OutputStream out; 
+	private WriteStrategy strategy = WriteStrategyMM.getInstance();
+	
+	public EndianAwareOutputStream(OutputStream os) {
+		out = os;
+	}
+	
+	public void setWriteStrategy(WriteStrategy strategy) 
+	{
+		this.strategy = strategy;
+	}
+	
+	public void write(int value) throws IOException {
+	  out.write(value);
+	}
+
+	public void writeBoolean(boolean value) throws IOException {
+		this.write(value ? 1 : 0);
+	}
+
+	public void writeByte(int value) throws IOException {
+		this.write(value);
+	}
+
+	public void writeBytes(String value) throws IOException {
+		new DataOutputStream(this).writeBytes(value);
+	}
+
+	public void writeChar(int value) throws IOException {
+		this.writeShort(value);
+	}
+
+	public void writeChars(String value) throws IOException {
+		int len = value.length();
+		
+		for (int i = 0 ; i < len ; i++) {
+			int v = value.charAt(i);
+		    this.writeShort(v);
+		}
+	}
+
+	public void writeDouble(double value) throws IOException {
+		 writeLong(Double.doubleToLongBits(value));
+	}
+
+	public void writeFloat(float value) throws IOException {
+		 writeInt(Float.floatToIntBits(value));
+	}
+
+	public void writeInt(int value) throws IOException {
+		byte[] buf = new byte[4];
+		strategy.writeInt(buf, 0, value);
+		this.write(buf, 0, 4);
+	}
+
+	public void writeLong(long value) throws IOException {
+		byte[] buf = new byte[8];
+		strategy.writeLong(buf, 0, value);
+		this.write(buf, 0, 8);
+	}
+	
+	public void writeS15Fixed16Number(float value) throws IOException {
+		byte[] buf = new byte[4];
+		strategy.writeS15Fixed16Number(buf, 0, value);
+		this.write(buf, 0, 4);
+	}
+
+	public void writeShort(int value) throws IOException {
+		byte[] buf = new byte[2];
+		strategy.writeShort(buf, 0, value);
+		this.write(buf, 0, 2);
+	}
+	
+	public void writeU16Fixed16Number(float value) throws IOException {
+		byte[] buf = new byte[4];
+		strategy.writeU16Fixed16Number(buf, 0, value);
+		this.write(buf, 0, 4);
+	}
+
+	public void writeU8Fixed8Number(float value) throws IOException {
+		byte[] buf = new byte[2];
+		strategy.writeU8Fixed8Number(buf, 0, value);
+		this.write(buf, 0, 2);
+	}
+	
+	public void writeUTF(String value) throws IOException {
+		new DataOutputStream(this).writeUTF(value);
+	}
+	
+	public void close() throws IOException {
+		out.close();
+	}
+}
diff --git a/src/pixy/io/FileCacheRandomAccessInputStream.java b/src/pixy/io/FileCacheRandomAccessInputStream.java
new file mode 100644
index 0000000..feb02ab
--- /dev/null
+++ b/src/pixy/io/FileCacheRandomAccessInputStream.java
@@ -0,0 +1,199 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.io;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.RandomAccessFile;
+
+/**
+ * Implements a file cached random access input stream to ease the 
+ * decoding of some types of images such as TIFF which may need random
+ * access to the underlying stream. 
+ * <p>
+ * Based on com.sun.media.jai.codec.FileCacheSeekableStream.
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 02/09/2014 
+ */ 
+public class FileCacheRandomAccessInputStream extends RandomAccessInputStream {
+
+	/** The cache File. */
+    private File cacheFile;
+
+    /** The cache as a RandomAcessFile. */
+    private RandomAccessFile cache;
+
+    /** The length of the read buffer. */
+    private int bufLen;
+
+    /** The read buffer. */
+    private byte[] buf;
+
+    /** Number of bytes in the cache. */
+    private long length = 0;
+
+    /** Next byte to be read. */
+    private long pointer = 0;
+
+    /** True if we've encountered the end of the source stream. */
+    private boolean foundEOF = false;
+
+    /**
+     * Constructs a <code>MemoryCacheRandomAccessInputStream</code>
+     * that takes its source data from a regular <code>InputStream</code>.
+     * Seeking backwards is supported by means of an file cache.
+     *
+     * <p> An <code>IOException</code> will be thrown if the
+     * attempt to create the cache file fails for any reason.
+     */
+    public FileCacheRandomAccessInputStream(InputStream stream) throws IOException {
+       this(stream, 4096); // 4k default buffer length
+    }
+    
+    public FileCacheRandomAccessInputStream(InputStream src, int bufLen) throws IOException {
+    	super(src);
+        this.bufLen = bufLen;
+        buf = new byte[bufLen];
+    	this.cacheFile = File.createTempFile("cafe-FCRAIS-", ".tmp");
+        cacheFile.deleteOnExit();
+        this.cache = new RandomAccessFile(cacheFile, "rw");
+    }
+
+    /**
+     * Ensures that at least <code>pos</code> bytes are cached,
+     * or the end of the source is reached.  The return value
+     * is equal to the smaller of <code>pos</code> and the
+     * length of the source file.
+     */
+    private long readUntil(long pos) throws IOException {
+        // We've already got enough data cached
+        if (pos < length) {
+            return pos;
+        }
+        // pos >= length but length isn't getting any bigger, so return it
+        if (foundEOF) {
+            return length;
+        }
+
+        long len = pos - length;
+        cache.seek(length);
+        while (len > 0) {
+            // Copy a buffer's worth of data from the source to the cache
+            // bufLen will always fit into an int so this is safe
+            int nbytes = src.read(buf, 0, (int)Math.min(len, bufLen));
+            if (nbytes == -1) {
+                foundEOF = true;
+                return length;
+            }
+
+            cache.setLength(cache.length() + nbytes);
+            cache.write(buf, 0, nbytes);
+            len -= nbytes;
+            length += nbytes;
+        }
+
+        return pos;
+    }
+
+    /**
+     * Returns the current offset in this stream.
+     *
+     * @return  the offset from the beginning of the stream, in bytes,
+     *          at which the next read occurs.
+     */
+    public long getStreamPointer() {
+         return pointer;
+    }
+
+    /**
+     * Sets the stream-pointer offset, measured from the beginning of this
+     * file, at which the next read occurs.
+     *
+     * @param  pos the offset position, measured in bytes from the
+     *             beginning of the stream, at which to set the stream
+     *                   pointer.
+     * @exception  IOException  if <code>pos</code> is less than
+     *                          <code>0</code> or if an I/O error occurs.
+     */
+    public void seek(long pos) throws IOException {
+    	ensureOpen();
+        if (pos < 0) {
+        	throw new IOException("Negtive seek position.");
+        }
+        pointer = pos;
+    }
+
+    public int read() throws IOException {
+    	ensureOpen();
+        long next = pointer + 1;
+        long pos = readUntil(next);
+        if (pos >= next) {
+            cache.seek(pointer++);
+            return cache.read();
+        }
+        return -1;    
+    }
+
+    public int read(byte[] b, int off, int len) throws IOException {
+    	ensureOpen();
+        if (b == null) {
+            throw new NullPointerException();
+        }
+        if ((off < 0) || (len < 0) || (off + len > b.length)) {
+            throw new IndexOutOfBoundsException();
+        }
+        if (len == 0) {
+            return 0;
+        }
+
+        long pos = readUntil(pointer + len);
+
+        // len will always fit into an int so this is safe
+        len = (int)Math.min(len, pos - pointer);
+        if (len > 0) {
+            cache.seek(pointer);
+            cache.readFully(b, off, len);
+            pointer += len;
+            return len;
+        }	        
+        return -1;
+    }
+
+    /**
+     * Closes this stream and releases any system resources
+     * associated with the stream.
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    public void close() throws IOException {
+		if(closed) return;
+        cache.close();
+        cacheFile.delete();
+        src.close();
+        src = null;
+        closed = true;
+    }
+    
+    public void shallowClose() throws IOException {
+    	if(closed) return;
+        cache.close();
+        cacheFile.delete();
+        src = null;
+        closed = true;
+    }
+}
diff --git a/src/pixy/io/FileCacheRandomAccessOutputStream.java b/src/pixy/io/FileCacheRandomAccessOutputStream.java
new file mode 100644
index 0000000..acbd85a
--- /dev/null
+++ b/src/pixy/io/FileCacheRandomAccessOutputStream.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * FileCacheRandomAccessOutputStream.java
+ *
+ * Who   Date       Description
+ * ====  =======    =================================================
+ * WY    07Apr2015  Removed flush() along with super flush()
+ * WY    06Apr2015  Added empty flush() to control flush timing
+ */
+
+package pixy.io;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.RandomAccessFile;
+
+public class FileCacheRandomAccessOutputStream extends RandomAccessOutputStream {
+
+	/** The cache File. */
+    private File cacheFile;
+
+    /** The cache as a RandomAcessFile. */
+    private RandomAccessFile cache;
+    
+    /** The length of the read buffer. */
+    private int bufLen = 4096;
+
+    /** Number of bytes in the cache. */
+    private long length = 0L;
+
+    /** Next byte to be read. */
+    private long pointer = 0L;
+    
+    private long flushPos = 0L;
+    
+    public FileCacheRandomAccessOutputStream(OutputStream dist) throws IOException {
+    	super(dist);
+        this.cacheFile = File.createTempFile("cafe-FCRAOS-", ".tmp");
+        cacheFile.deleteOnExit();
+        this.cache = new RandomAccessFile(cacheFile, "rw");
+    }
+    
+    public FileCacheRandomAccessOutputStream(OutputStream dist, int bufLen) throws IOException {
+    	super(dist);
+    	this.bufLen = bufLen;
+        this.cacheFile = File.createTempFile("cafe-FCRAOS-", ".tmp");
+        cacheFile.deleteOnExit();
+        this.cache = new RandomAccessFile(cacheFile, "rw");
+    }
+    
+    /**
+     * Closes this stream and releases any system resources
+     * associated with the stream.
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    public void close() throws IOException {
+    	if(closed) return;
+        super.close();
+        cache.close();
+        cacheFile.delete();
+        dist.close();
+        dist = null;
+        closed = true;
+     }
+    
+	@Override
+	public void disposeBefore(long pos) { 
+		throw new UnsupportedOperationException("This method is not implemented");
+	}
+	
+	@Override
+	public long getFlushPos() {
+		return flushPos;
+	}
+
+	@Override
+	public long getLength() {
+		return length;
+	}
+
+	@Override
+	public long getStreamPointer() {
+		return pointer;
+	}
+	
+	@Override
+	public void reset() { }
+
+	@Override
+	 public void seek(long pos) throws IOException {
+		ensureOpen();
+        if (pos < 0) {
+        	throw new IOException("Negtive seek position.");
+        }
+        pointer = pos;
+    }
+
+	@Override
+	public void write(byte[] b, int off, int len) throws IOException {
+		ensureOpen();
+		if (b == null) {
+			throw new NullPointerException("b == null!");
+		}
+	       
+		if ((off < 0) || (len < 0) || (pointer < 0) ||
+				(off + len > b.length) || (off + len < 0)) {
+			throw new IndexOutOfBoundsException();
+		}
+		
+		long lastPos = pointer + len - 1;
+		
+		if (lastPos >= length) {
+			length = lastPos + 1;
+		}
+		
+		cache.seek(pointer);
+		cache.write(b, off, len);
+		pointer += len;
+	}
+	
+	@Override
+	public void write(int value) throws IOException {
+		ensureOpen();
+		if (pointer < 0)
+	    	throw new IndexOutOfBoundsException("pointer < 0");
+		if (pointer >= length) {
+           length = pointer + 1;
+        }
+		cache.seek(pointer);
+        cache.write(value);
+    	pointer++;
+    }
+
+	@Override
+	public void writeToStream(long len) throws IOException {
+		ensureOpen();
+		if (len == 0) {
+            return;
+        }
+		
+		if (pointer + len > length) {
+            throw new IndexOutOfBoundsException("Argument out of cache");
+        }
+        
+        if ((pointer < 0) || (len < 0)) {
+            throw new IndexOutOfBoundsException("Negative pointer or len");
+        }
+        
+        cache.seek(pointer);
+
+        while (len > 0) {
+    	   byte[] buf = new byte[bufLen];
+           int nbytes = cache.read(buf);
+           dist.write(buf, 0, nbytes);
+           len -= nbytes;
+           flushPos += nbytes;
+        }
+    }
+
+	@Override
+	public void shallowClose() throws IOException {
+	   	if(closed) return;
+        super.close();
+        cache.close();
+        cacheFile.delete();
+        dist = null;
+        closed = true;
+	}
+}
diff --git a/src/pixy/io/FileCacheSeekableStream.java b/src/pixy/io/FileCacheSeekableStream.java
new file mode 100644
index 0000000..1add043
--- /dev/null
+++ b/src/pixy/io/FileCacheSeekableStream.java
@@ -0,0 +1,247 @@
+/**
+ * This is part of the JAI API
+ */
+
+package pixy.io;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.RandomAccessFile;
+
+/**
+ * A subclass of <code>SeekableStream</code> that may be used to wrap
+ * a regular <code>InputStream</code>.  Seeking backwards is supported
+ * by means of a file cache.  In circumstances that do not allow the
+ * creation of a temporary file (for example, due to security
+ * consideration or the absence of local disk), the
+ * <code>MemoryCacheSeekableStream</code> class may be used instead.
+ *
+ * <p> The <code>mark()</code> and <code>reset()</code> methods are
+ * supported.
+ *
+ * <p><b> This class is not a committed part of the JAI API.  It may
+ * be removed or changed in future releases of JAI.</b>
+ *
+ * @version $Id: FileCacheSeekableStream.java 498740 2007-01-22 18:35:57Z dvholten $
+ */
+public final class FileCacheSeekableStream extends SeekableStream {
+
+    /** The source stream. */
+    private InputStream stream;
+
+    /** The cache File. */
+    private File cacheFile;
+
+    /** The cache as a RandomAcessFile. */
+    private RandomAccessFile cache;
+
+    /** The length of the read buffer. */
+    private int bufLen = 1024;
+
+    /** The read buffer. */
+    private byte[] buf = new byte[bufLen];
+
+    /** Number of bytes in the cache. */
+    private long length = 0;
+
+    /** Next byte to be read. */
+    private long pointer = 0;
+
+    /** True if we've encountered the end of the source stream. */
+    private boolean foundEOF = false;
+
+    /**
+     * Constructs a <code>MemoryCacheSeekableStream</code> that takes
+     * its source data from a regular <code>InputStream</code>.
+     * Seeking backwards is supported by means of an file cache.
+     *
+     * <p> An <code>IOException</code> will be thrown if the
+     * attempt to create the cache file fails for any reason.
+     */
+    public FileCacheSeekableStream(InputStream stream)
+        throws IOException {
+        this.stream = stream;
+        this.cacheFile = File.createTempFile("jai-FCSS-", ".tmp");
+        cacheFile.deleteOnExit();
+        this.cache = new RandomAccessFile(cacheFile, "rw");
+    }
+
+    /**
+     * Ensures that at least <code>pos</code> bytes are cached,
+     * or the end of the source is reached.  The return value
+     * is equal to the smaller of <code>pos</code> and the
+     * length of the source file.
+     */
+    private long readUntil(long pos) throws IOException {
+        // We've already got enough data cached
+        if (pos < length) {
+            return pos;
+        }
+        // pos >= length but length isn't getting any bigger, so return it
+        if (foundEOF) {
+            return length;
+        }
+
+        long len = pos - length;
+        cache.seek(length);
+        while (len > 0) {
+            // Copy a buffer's worth of data from the source to the cache
+            // bufLen will always fit into an int so this is safe
+            int nbytes = stream.read(buf, 0, (int)Math.min(len, bufLen));
+            if (nbytes == -1) {
+                foundEOF = true;
+                return length;
+            }
+
+            cache.setLength(cache.length() + nbytes);
+            cache.write(buf, 0, nbytes);
+            len -= nbytes;
+            length += nbytes;
+        }
+
+        return pos;
+    }
+
+    /**
+     * Returns <code>true</code> since all
+     * <code>FileCacheSeekableStream</code> instances support seeking
+     * backwards.
+     */
+    public boolean canSeekBackwards() {
+        return true;
+    }
+
+    /**
+     * Returns the current offset in this file.
+     *
+     * @return     the offset from the beginning of the file, in bytes,
+     *             at which the next read occurs.
+     */
+    public long getFilePointer() {
+        return pointer;
+    }
+
+    /**
+     * Sets the file-pointer offset, measured from the beginning of this
+     * file, at which the next read occurs.
+     *
+     * @param      pos   the offset position, measured in bytes from the
+     *                   beginning of the file, at which to set the file
+     *                   pointer.
+     * @exception  IOException  if <code>pos</code> is less than
+     *                          <code>0</code> or if an I/O error occurs.
+     */
+    public void seek(long pos) throws IOException {
+        if (pos < 0) {
+            throw new IOException(PropertyUtil.getString("FileCacheSeekableStream0"));
+        }
+        pointer = pos;
+    }
+
+    /**
+     * Reads the next byte of data from the input stream. The value byte is
+     * returned as an <code>int</code> in the range <code>0</code> to
+     * <code>255</code>. If no byte is available because the end of the stream
+     * has been reached, the value <code>-1</code> is returned. This method
+     * blocks until input data is available, the end of the stream is detected,
+     * or an exception is thrown.
+     *
+     * @return     the next byte of data, or <code>-1</code> if the end of the
+     *             stream is reached.
+     * @exception  IOException  if an I/O error occurs.
+     */
+    public int read() throws IOException {
+        long next = pointer + 1;
+        long pos = readUntil(next);
+        if (pos >= next) {
+            cache.seek(pointer++);
+            return cache.read();
+        }
+        return -1;    
+    }
+
+    /**
+     * Reads up to <code>len</code> bytes of data from the input stream into
+     * an array of bytes.  An attempt is made to read as many as
+     * <code>len</code> bytes, but a smaller number may be read, possibly
+     * zero. The number of bytes actually read is returned as an integer.
+     *
+     * <p> This method blocks until input data is available, end of file is
+     * detected, or an exception is thrown.
+     *
+     * <p> If <code>b</code> is <code>null</code>, a
+     * <code>NullPointerException</code> is thrown.
+     *
+     * <p> If <code>off</code> is negative, or <code>len</code> is negative, or
+     * <code>off+len</code> is greater than the length of the array
+     * <code>b</code>, then an <code>IndexOutOfBoundsException</code> is
+     * thrown.
+     *
+     * <p> If <code>len</code> is zero, then no bytes are read and
+     * <code>0</code> is returned; otherwise, there is an attempt to read at
+     * least one byte. If no byte is available because the stream is at end of
+     * file, the value <code>-1</code> is returned; otherwise, at least one
+     * byte is read and stored into <code>b</code>.
+     *
+     * <p> The first byte read is stored into element <code>b[off]</code>, the
+     * next one into <code>b[off+1]</code>, and so on. The number of bytes read
+     * is, at most, equal to <code>len</code>. Let <i>k</i> be the number of
+     * bytes actually read; these bytes will be stored in elements
+     * <code>b[off]</code> through <code>b[off+</code><i>k</i><code>-1]</code>,
+     * leaving elements <code>b[off+</code><i>k</i><code>]</code> through
+     * <code>b[off+len-1]</code> unaffected.
+     *
+     * <p> In every case, elements <code>b[0]</code> through
+     * <code>b[off]</code> and elements <code>b[off+len]</code> through
+     * <code>b[b.length-1]</code> are unaffected.
+     *
+     * <p> If the first byte cannot be read for any reason other than end of
+     * file, then an <code>IOException</code> is thrown. In particular, an
+     * <code>IOException</code> is thrown if the input stream has been closed.
+     *
+     * @param      b     the buffer into which the data is read.
+     * @param      off   the start offset in array <code>b</code>
+     *                   at which the data is written.
+     * @param      len   the maximum number of bytes to read.
+     * @return     the total number of bytes read into the buffer, or
+     *             <code>-1</code> if there is no more data because the end of
+     *             the stream has been reached.
+     * @exception  IOException  if an I/O error occurs.
+     */
+    public int read(byte[] b, int off, int len) throws IOException {
+        if (b == null) {
+            throw new NullPointerException();
+        }
+        if ((off < 0) || (len < 0) || (off + len > b.length)) {
+            throw new IndexOutOfBoundsException();
+        }
+        if (len == 0) {
+            return 0;
+        }
+
+        long pos = readUntil(pointer + len);
+
+        // len will always fit into an int so this is safe
+        len = (int)Math.min(len, pos - pointer);
+        if (len > 0) {
+            cache.seek(pointer);
+            cache.readFully(b, off, len);
+            pointer += len;
+            return len;
+        }	        
+        return -1;
+      }
+
+    /**
+     * Closes this stream and releases any system resources
+     * associated with the stream.
+     *
+     * @throws IOException if an I/O error occurs.
+     */
+    public void close() throws IOException {
+        super.close();
+        cache.close();
+        cacheFile.delete();
+    }
+}
diff --git a/src/pixy/io/ForwardSeekableStream.java b/src/pixy/io/ForwardSeekableStream.java
new file mode 100644
index 0000000..3f11abf
--- /dev/null
+++ b/src/pixy/io/ForwardSeekableStream.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.io;
+
+import java.io.InputStream;
+import java.io.IOException;
+
+/**
+ * A subclass of <code>SeekableStream</code> that may be used
+ * to wrap a regular <code>InputStream</code> efficiently.
+ * Seeking backwards is not supported.
+ *
+ * <p><b> This class is not a committed part of the JAI API.  It may
+ * be removed or changed in future releases of JAI.</b>
+ */
+public class ForwardSeekableStream extends SeekableStream {
+
+    /** The source <code>InputStream</code>. */
+    private InputStream src;
+
+    /** The current position. */
+    long pointer = 0L;
+
+    /** The marked position. */
+    long markPos = -1L;
+
+    /** 
+     * Constructs a <code>InputStreamForwardSeekableStream</code> from a
+     * regular <code>InputStream</code>.
+     */
+    public ForwardSeekableStream(InputStream src) {
+        this.src = src;
+    }
+
+    /** Forwards the request to the real <code>InputStream</code>. */
+    public final int read() throws IOException {
+        int result = src.read();
+        if (result != -1) {
+            ++pointer;
+        }
+        return result;
+    }
+
+    /** Forwards the request to the real <code>InputStream</code>. */
+    public final int read(byte[] b, int off, int len) throws IOException {
+        int result = src.read(b, off, len);
+        if (result != -1) {
+            pointer += result;
+        }
+        return result;
+    }
+
+    /** Forwards the request to the real <code>InputStream</code>. */
+    public final long skip(long n) throws IOException {
+        long skipped = src.skip(n);
+        pointer += skipped;
+        return skipped;
+    }
+
+    /** Forwards the request to the real <code>InputStream</code>. */
+    public final int available() throws IOException {
+        return src.available();
+    }
+
+    /** Forwards the request to the real <code>InputStream</code>. */
+    public final void close() throws IOException {
+        src.close();
+    }
+
+    /** Forwards the request to the real <code>InputStream</code>. */
+    public synchronized final void mark(int readLimit) {
+        markPos = pointer;
+        src.mark(readLimit);
+    }
+
+    /** Forwards the request to the real <code>InputStream</code>. */
+    public synchronized final void reset() throws IOException {
+        if (markPos != -1) {
+            pointer = markPos;
+        }
+        src.reset();
+    }
+
+    /** Forwards the request to the real <code>InputStream</code>. */
+    public boolean markSupported() {
+        return src.markSupported();
+    }
+
+    /** Returns <code>false</code> since seking backwards is not supported. */
+    public final boolean canSeekBackwards() {
+        return false;
+    }
+
+    /** Returns the current position in the stream (bytes read). */
+    public final long getFilePointer() {
+        return pointer;
+    }
+
+    /**
+     * Seeks forward to the given position in the stream.
+     * If <code>pos</code> is smaller than the current position
+     * as returned by <code>getFilePointer()</code>, nothing
+     * happens.
+     */
+    public final void seek(long pos) throws IOException {
+        while (pos - pointer > 0) {
+            pointer += src.skip(pos - pointer);
+        }
+    }
+}
diff --git a/src/pixy/io/IOUtils.java b/src/pixy/io/IOUtils.java
new file mode 100644
index 0000000..db5ed8c
--- /dev/null
+++ b/src/pixy/io/IOUtils.java
@@ -0,0 +1,340 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.io;
+
+import java.io.ByteArrayOutputStream;
+import java.io.EOFException;
+import java.io.InputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * General purpose IO helper class
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 10/12/2012
+ */
+public class IOUtils {
+	//
+	public static final short LITTLE_ENDIAN = 0x4949;//II Intel
+	public static final short BIG_ENDIAN = 0x4d4d;//MM Motorola	
+	
+	public static void close(InputStream is) throws IOException {
+		is.close();
+	}
+	 
+	public static void close(OutputStream os) throws IOException {
+		os.close();
+	}
+	 
+	public static byte[] inputStreamToByteArray(InputStream is) throws IOException {
+		 
+		ByteArrayOutputStream bout = new ByteArrayOutputStream(4096);
+		byte[] buf = new byte[4096];
+		int len = 0;
+		
+		while((len = is.read(buf)) != -1) {
+			bout.write(buf, 0, len);
+		}
+		
+		bout.close();
+		 
+		return bout.toByteArray();
+	}
+     
+	public static int read(InputStream is) throws IOException {
+		return is.read();
+	}
+   
+	public static int read(InputStream is, byte[] bytes) throws IOException {
+		return is.read(bytes);
+	}     
+     
+	public static int read(InputStream is, byte[] bytes, int off, int len) throws IOException {
+		return is.read(bytes, off, len);
+	}
+     
+	public static double readDouble(InputStream is) throws IOException {
+		return Double.longBitsToDouble(readLong(is));
+	}
+	 
+	public static double readDoubleMM(InputStream is) throws IOException {
+		return Double.longBitsToDouble(readLongMM(is));
+	}	
+	 
+	public static float readFloat(InputStream is) throws IOException {
+		return Float.intBitsToFloat(readInt(is));
+	}
+
+	public static float readFloatMM(InputStream is) throws IOException {
+		return Float.intBitsToFloat(readIntMM(is));
+	}
+	 
+	public static byte[] readFully(InputStream is, int bufLen) throws IOException {
+		ByteArrayOutputStream bout = new ByteArrayOutputStream(bufLen);
+		byte[] buf = new byte[bufLen];
+		int count = is.read(buf);         
+		
+		while (count > 0) {
+			bout.write(buf, 0, count);
+			count = is.read(buf);
+		}
+		
+		return bout.toByteArray();
+	}
+	
+	public static void readFully(InputStream is, byte b[]) throws IOException {
+		readFully(is, b, 0, b.length);
+	}
+	 
+	public static void readFully(InputStream is, byte[] b, int off, int len) throws IOException {
+		if (len < 0)
+			throw new IndexOutOfBoundsException();
+		int n = 0;         
+		while (n < len) {
+			int count = is.read(b, off + n, len - n);
+			if (count < 0)
+				throw new EOFException();
+			n += count;
+		}
+	}
+	 
+	public static int readInt(byte[] buf, int start_idx) { 
+		return ((buf[start_idx++]&0xff)|((buf[start_idx++]&0xff)<<8)|
+				((buf[start_idx++]&0xff)<<16)|((buf[start_idx++]&0xff)<<24));
+	}
+
+	public static int readInt(InputStream is) throws IOException {
+		byte[] buf = new byte[4];
+		readFully(is, buf);
+		 
+		return (((buf[3]&0xff)<<24)|((buf[2]&0xff)<<16)|((buf[1]&0xff)<<8)|(buf[0]&0xff));
+	}
+	 
+	public static int readIntMM(byte[] buf, int start_idx) { 
+		return (((buf[start_idx++]&0xff)<<24)|((buf[start_idx++]&0xff)<<16)|
+				((buf[start_idx++]&0xff)<<8)|(buf[start_idx++]&0xff));
+	}
+
+	public static int readIntMM(InputStream is) throws IOException {
+		byte[] buf = new byte[4];
+		readFully(is, buf);
+		 
+		return (((buf[0]&0xff)<<24)|((buf[1]&0xff)<<16)|
+				((buf[2]&0xff)<<8)|(buf[3]&0xff));
+	}
+
+	public static long readLong(byte[] buf, int start_idx) {    	 
+		return ((buf[start_idx++]&0xffL)|(((buf[start_idx++]&0xffL)<<8)|((buf[start_idx++]&0xffL)<<16)|
+				((buf[start_idx++]&0xffL)<<24)|((buf[start_idx++]&0xffL)<<32)|((buf[start_idx++]&0xffL)<<40)|
+				((buf[start_idx++]&0xffL)<<48)|(buf[start_idx]&0xffL)<<56));
+	}
+
+	public static long readLong(InputStream is) throws IOException {
+		byte[] buf = new byte[8];
+		readFully(is, buf);
+		 
+		return (((buf[7]&0xffL)<<56)|((buf[6]&0xffL)<<48)|
+				((buf[5]&0xffL)<<40)|((buf[4]&0xffL)<<32)|((buf[3]&0xffL)<<24)|
+				((buf[2]&0xffL)<<16)|((buf[1]&0xffL)<<8)|(buf[0]&0xffL));
+	}
+
+	public static long readLongMM(byte[] buf, int start_idx) {		 
+		return (((buf[start_idx++]&0xffL)<<56)|((buf[start_idx++]&0xffL)<<48)|
+				((buf[start_idx++]&0xffL)<<40)|((buf[start_idx++]&0xffL)<<32)|((buf[start_idx++]&0xffL)<<24)|
+				((buf[start_idx++]&0xffL)<<16)|((buf[start_idx++]&0xffL)<<8)|(buf[start_idx]&0xffL));
+	}
+
+	public static long readLongMM(InputStream is) throws IOException {
+		byte[] buf = new byte[8];
+		readFully(is, buf);
+		 
+		return (((buf[0]&0xffL)<<56)|((buf[1]&0xffL)<<48)|
+				((buf[2]&0xffL)<<40)|((buf[3]&0xffL)<<32)|((buf[4]&0xffL)<<24)|
+				((buf[5]&0xffL)<<16)|((buf[6]&0xffL)<<8)|(buf[7]&0xffL));
+	}
+	 
+	public static float readS15Fixed16MMNumber(byte[] buf, int start_idx) { 
+		short s15 = (short)(((buf[start_idx++]&0xff)<<8)|(buf[start_idx++]&0xff));
+		int fixed16 = (((buf[start_idx++]&0xff)<<8)|(buf[start_idx]&0xff));
+		 
+		return s15 + fixed16/65536.0f;
+	}
+	 
+	public static float readS15Fixed16MMNumber(InputStream is) throws IOException { 		
+		byte[] buf = new byte[4];
+		IOUtils.readFully(is, buf);
+		 
+		short s15 = (short)((buf[1]&0xff)|((buf[0]&0xff)<<8));
+		int fixed16 = ((buf[3]&0xff)|((buf[2]&0xff)<<8));
+		 
+		return s15 + fixed16/65536.0f;	
+	}
+	
+	public static float readS15Fixed16Number(byte[] buf, int start_idx) {
+		short s15 = (short)((buf[start_idx++]&0xff)|((buf[start_idx++]&0xff)<<8));
+		int fixed16 = ((buf[start_idx++]&0xff)|((buf[start_idx]&0xff)<<8));
+		 
+		return s15 + fixed16/65536.0f;
+	}
+	 
+	public static float readS15Fixed16Number(InputStream is) throws IOException { 		
+		byte[] buf = new byte[4];
+		IOUtils.readFully(is, buf);
+		 
+		short s15 = (short)((buf[0]&0xff)|((buf[1]&0xff)<<8));
+		int fixed16 = ((buf[2]&0xff)|((buf[3]&0xff)<<8));
+		 
+		return s15 + fixed16/65536.0f;	
+	}
+	
+	public static short readShort(byte[] buf, int start_idx) { 
+		return (short)((buf[start_idx++]&0xff)|((buf[start_idx]&0xff)<<8));
+	}
+
+	public static short readShort(InputStream is) throws IOException { 
+		byte[] buf = new byte[2];
+		readFully(is, buf);
+		
+		return (short)(((buf[1]&0xff)<<8)|(buf[0]&0xff));
+	}
+	
+	public static short readShortMM(byte[] buf, int start_idx) { 
+		return (short)(((buf[start_idx++]&0xff)<<8)|(buf[start_idx]&0xff));
+	}
+
+	public static short readShortMM(InputStream is) throws IOException { 
+		byte[] buf = new byte[2];
+		readFully(is, buf);
+		
+		return (short)(((buf[0]&0xff)<<8)|(buf[1]&0xff));
+	}
+	 
+	public static long readUnsignedInt(byte[] buf, int start_idx) { 
+		return ((buf[start_idx++]&0xff)|((buf[start_idx++]&0xff)<<8)|
+				((buf[start_idx++]&0xff)<<16)|((buf[start_idx++]&0xff)<<24))& 0xffffffffL;
+	}
+	
+	public static long readUnsignedInt(InputStream is) throws IOException {
+		byte[] buf = new byte[4];
+		readFully(is, buf);
+		
+		return (((buf[3]&0xff)<<24)|((buf[2]&0xff)<<16)|
+				((buf[1]&0xff)<<8)|(buf[0]&0xff))& 0xffffffffL;
+	}
+	
+	public static long readUnsignedIntMM(byte[] buf, int start_idx)	{ 
+		return readIntMM(buf, start_idx) & 0xffffffffL;
+	}
+	
+	public static long readUnsignedIntMM(InputStream is) throws IOException	{
+		return readIntMM(is) & 0xffffffffL;
+	}
+	
+	public static int readUnsignedShort(byte[] buf, int start_idx) { 
+		return (buf[start_idx++]&0xff)|((buf[start_idx]&0xff)<<8);
+	}
+	
+	public static int readUnsignedShort(InputStream is) throws IOException {
+		byte[] buf = new byte[2];
+		readFully(is, buf);
+		
+		return ((buf[1]&0xff)<<8)|(buf[0]&0xff);
+	}
+	
+	public static int readUnsignedShortMM(byte[] buf, int start_idx) { 
+		return ((buf[start_idx++]&0xff)<<8)|(buf[start_idx]&0xff);
+	}
+	
+	public static int readUnsignedShortMM(InputStream is) throws IOException { 
+		byte[] buf = new byte[2];
+		readFully(is, buf);
+		
+		return ((buf[0]&0xff)<<8)|(buf[1]&0xff);
+	}
+	
+	public static long skip(InputStream is, long len) throws IOException {
+		return is.skip(len);
+	}
+	
+	public static void skipFully(InputStream is, int n) throws IOException {
+		readFully(is, new byte[n]);
+	}	
+	 
+	public static void write(OutputStream os, byte[] bytes) throws IOException {
+		os.write(bytes);
+	}
+	 
+	public static void write(OutputStream os, byte[] bytes, int off, int len) throws IOException {
+		os.write(bytes, off, len);
+	}
+	 
+	public static void write(OutputStream os, int abyte) throws IOException {
+		os.write(abyte);
+	}
+	
+	// Write an int to the OutputStream with Intel format 
+	public static void writeInt(OutputStream os, int value) throws IOException {
+		os.write(new byte[] {
+	        (byte)value,
+	        (byte)(value>>>8),
+	        (byte)(value>>>16),
+	        (byte)(value>>>24)});
+	}
+    
+	// Write an int to the OutputStream with Motorola format 
+	public static void writeIntMM(OutputStream os, int value) throws IOException {
+		os.write(new byte[] {
+			(byte)(value >>> 24),
+			(byte)(value >>> 16),
+			(byte)(value >>> 8),
+			(byte)value});
+	}
+	
+	public static void writeLong(OutputStream os, long value) throws IOException {
+		os.write(new byte[] {
+	        (byte)value, (byte)(value>>>8),
+	        (byte)(value>>>16), (byte)(value>>>24),
+	        (byte)(value>>>32), (byte)(value>>>40),
+		    (byte)(value>>>48), (byte)(value>>>56)});
+	}
+	 
+	public static void writeLongMM(OutputStream os, long value) throws IOException {
+		os.write(new byte[] {
+			(byte)(value>>>56),
+			(byte)(value>>>48),
+			(byte)(value>>>40),
+			(byte)(value>>>32),
+			(byte)(value>>>24),
+			(byte)(value>>>16),
+			(byte)(value>>>8),
+			(byte)value});
+	}
+	
+	public static void writeShort(OutputStream os, int value) throws IOException {
+		os.write(new byte[] {
+		  (byte)value,
+		  (byte)(value >>> 8)});
+	}
+	 
+	public static void writeShortMM(OutputStream os, int value) throws IOException {
+		os.write(new byte[] {
+			(byte)(value >>> 8),
+			(byte)value});
+	}
+	
+	private IOUtils() {}
+}
diff --git a/src/pixy/io/MemoryCacheRandomAccessInputStream.java b/src/pixy/io/MemoryCacheRandomAccessInputStream.java
new file mode 100644
index 0000000..960cf4e
--- /dev/null
+++ b/src/pixy/io/MemoryCacheRandomAccessInputStream.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.io;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implements a memory cached random access input stream to ease the 
+ * decoding of some types of images such as TIFF which may need random
+ * access to the underlying stream. 
+ * <p>
+ * Based on com.sun.media.jai.codec.MemoryCacheSeekableStream.
+ * <p>
+ * This implementation has a major drawback: It has no knowledge 
+ * of the length of the stream, it is supposed to move forward
+ * even though it is possible to put the pointer at anywhere
+ * before the end of the stream. 
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 02/09/2014 
+ */ 
+public class MemoryCacheRandomAccessInputStream extends RandomAccessInputStream {
+	//
+	private static final int BUFFER_SHIFT = 12;
+    private static final int BUFFER_SIZE = 1 << BUFFER_SHIFT;
+    private static final int BUFFER_MASK = BUFFER_SIZE - 1;
+
+    private long pointer;
+    private List<byte[]> cache;
+    private int length;
+    private boolean foundEOS;
+	    
+	public MemoryCacheRandomAccessInputStream(InputStream src) {
+		super(src);
+		pointer = 0L;
+		cache = new ArrayList<byte[]>(10);
+		length = 0;
+		foundEOS = false;
+	}
+		
+	public void close() throws IOException {
+		if(closed) return;
+		super.close();
+		cache.clear();
+		cache = null;
+		src.close();
+		src = null;
+		closed = true;
+	}
+		
+	public long getStreamPointer() {
+		return pointer;
+	}
+		
+	public int read() throws IOException {
+		ensureOpen();
+		long l = pointer + 1L;
+		long pos = readUntil(l);
+		if(pos >= l) {
+			byte[] buf = cache.get((int)(pointer>>BUFFER_SHIFT));
+			return buf[(int)(pointer++ & BUFFER_MASK)] & 0xff;
+		}
+	        
+		return -1;
+	}
+
+	public int read(byte[] bytes, int off, int len) throws IOException {
+		ensureOpen();
+		if(bytes == null)
+			throw new NullPointerException();
+		if(off<0 || len<0 || off+len>bytes.length)
+			throw new IndexOutOfBoundsException();
+		if(len == 0)
+			return 0;
+		long l = readUntil(pointer+len);
+		if (l <= pointer)
+			return -1;
+	        
+		byte[] buf = cache.get((int)(pointer >> BUFFER_SHIFT));
+		int k = Math.min(len, BUFFER_SIZE - (int)(pointer & BUFFER_MASK));
+		System.arraycopy(buf, (int)(pointer & BUFFER_MASK), bytes, off, k);
+	        
+		pointer += k;
+	        
+		return k;
+	}
+
+	private long readUntil(long pos) throws IOException {		
+		if(pos < length)
+			return pos;
+		if(foundEOS)
+			return length;
+		int slot = (int)(pos >> BUFFER_SHIFT);
+		int startSlot = length >> BUFFER_SHIFT;
+	        
+		for(int k = startSlot; k <= slot; k++) 
+		{
+			byte[] buf = new byte[BUFFER_SIZE];
+			cache.add(buf);
+			int len = BUFFER_SIZE;
+			int off = 0;
+	            
+			while(len > 0) {
+				int nbytes = src.read(buf, off, len);
+				if(nbytes == -1) {
+					foundEOS = true;
+					return length;
+				}
+				off += nbytes;
+				len -= nbytes;
+				length += nbytes;
+			}
+		}
+		return length;
+	}
+
+	public void seek(long loc) throws IOException {
+		ensureOpen();
+		if (loc<0L)
+			throw new IOException("Negtive seek position.");
+			
+		pointer = loc;
+	}
+
+	@Override
+	public void shallowClose() throws IOException {
+		if(closed) return;
+		super.close();
+		cache.clear();
+		cache = null;
+		src = null;
+		closed = true;		
+	}
+}
diff --git a/src/pixy/io/MemoryCacheRandomAccessOutputStream.java b/src/pixy/io/MemoryCacheRandomAccessOutputStream.java
new file mode 100644
index 0000000..2f28342
--- /dev/null
+++ b/src/pixy/io/MemoryCacheRandomAccessOutputStream.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * MemoryCacheRandomAccessOutputStream.java
+ *
+ * Who   Date       Description
+ * ====  =======    =================================================
+ * WY    07Apr2015  Removed flush() along with super flush()
+ * WY    06Apr2015  Added empty flush() to control flush timing
+ */
+
+package pixy.io;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+public class MemoryCacheRandomAccessOutputStream extends RandomAccessOutputStream {
+	private static final int BUFFER_SHIFT = 12;
+	private static final int BUFFER_SIZE = 1 << BUFFER_SHIFT;
+	private static final int BUFFER_MASK = BUFFER_SIZE - 1;
+	
+	private long pointer = 0L;
+	// The largest position ever written to the cache.
+	private long length = 0L;
+	private List<byte[]> cache;
+	private long cacheStart = 0L;
+	private long flushPos = 0L;
+	
+	public MemoryCacheRandomAccessOutputStream(OutputStream dist) {
+		super(dist);
+		cache = new ArrayList<byte[]>(10);
+	}
+	
+	public void close() throws IOException {
+		if(closed) return;
+		super.close();
+ 		cache.clear();
+ 		cache = null;
+ 		dist.close();
+ 		dist = null;
+ 		closed = true;
+    }	
+
+	public void disposeBefore(long pos) throws IOException {
+		ensureOpen();
+	    long index = pos >> BUFFER_SHIFT;
+	    
+	    if (index < cacheStart) {
+	         throw new IndexOutOfBoundsException("pos already disposed");
+	    }
+	    
+	    long numBlocks = Math.min(index - cacheStart, cache.size());
+	    
+	    for (long i = 0; i < numBlocks; i++) {
+	         cache.remove(0);
+	    }
+	    
+	    this.cacheStart = index;
+    }
+	
+	private void expandCache(long pos) throws IOException {
+        long currIndex = cacheStart + cache.size() - 1;
+        long toIndex = pos >> BUFFER_SHIFT;
+        long numNewBuffers = toIndex - currIndex;
+        // Fill the cache with blocks to the position required for writing.
+        for (long i = 0; i < numNewBuffers; i++) {
+            try {
+                cache.add(new byte[BUFFER_SIZE]);
+            } catch (OutOfMemoryError e) {
+                throw new IOException("No memory left for cache!");
+            }
+        }
+    }
+	
+	private byte[] getCacheBlock(long blockNum) throws IOException {
+        long blockOffset = blockNum - cacheStart;
+        if (blockOffset > Integer.MAX_VALUE) {
+            throw new IOException("Cache addressing limit exceeded!");
+        }
+        return cache.get((int)blockOffset);
+    }
+	
+	public long getFlushPos() {
+		return flushPos;
+	}
+	
+	/**
+	 * Returns the total length of data that has been cached,
+	 * regardless of whether any early blocks have been disposed.
+	 * This value will only ever increase. 
+	 */
+	public long getLength() {
+	    return length;
+	}	
+	
+	public long getStreamPointer() {
+	  	return pointer;
+	}
+	
+	@Override
+	public void reset() {
+		throw new UnsupportedOperationException("This method is not implemented");
+	}
+	
+	public void seek(long pos) throws IOException {
+		ensureOpen();
+        if (pos < 0L)
+        	throw new IOException("Negative seek position.");
+		
+        pointer = pos;
+    }
+	
+	public void write(byte[] b, int off, int len) throws IOException {
+		ensureOpen();
+        if (b == null) {
+            throw new NullPointerException("b == null!");
+        }
+       
+        if ((off < 0) || (len < 0) || (pointer < 0) ||
+            (off + len > b.length) || (off + len < 0)) {
+            throw new IndexOutOfBoundsException();
+        }
+        // Ensure there is space for the incoming data
+        long lastPos = pointer + len - 1;
+        if (lastPos >= length) {
+            expandCache(lastPos);
+            length = lastPos + 1;
+        }
+        // Copy the data into the cache, block by block
+        int offset = (int)(pointer & BUFFER_MASK);
+        while (len > 0) {
+            byte[] buf = getCacheBlock(pointer >> BUFFER_SHIFT);
+            int nbytes = Math.min(len, BUFFER_SIZE - offset);
+            System.arraycopy(b, off, buf, offset, nbytes);
+
+            pointer += nbytes;
+            off += nbytes;
+            len -= nbytes;
+            offset = 0; // Always after the first time
+        }
+    }
+	
+	@Override
+	public void write(int value) throws IOException {
+		ensureOpen();
+	    if (pointer < 0)
+	    	throw new ArrayIndexOutOfBoundsException("pointer < 0");
+		// Ensure there is space for the incoming data
+        if (pointer >= length) {
+            expandCache(pointer);
+            length = pointer + 1;
+        }
+        // Insert the data.
+        byte[] buf = getCacheBlock(pointer >> BUFFER_SHIFT);
+        int offset = (int)(pointer++ & BUFFER_MASK);
+        buf[offset] = (byte)value;
+	}
+
+	public void writeToStream(long len) throws IOException {
+		ensureOpen();
+		if (len == 0) {
+            return;
+        }
+		
+		if (pointer + len > length) {
+            throw new IndexOutOfBoundsException("Argument out of cache");
+        }
+        
+        if ((pointer < 0) || (len < 0)) {
+            throw new IndexOutOfBoundsException("Negative pointer or len");
+        }      
+
+        long bufIndex = pointer >> BUFFER_SHIFT;
+
+        if (bufIndex < cacheStart) {
+            throw new IndexOutOfBoundsException("pointer already disposed");
+        }
+        
+        int offset = (int)(pointer & BUFFER_MASK);
+        byte[] buf = getCacheBlock(bufIndex++);
+        	
+        while (len > 0) {
+            if (buf == null) {
+                buf = getCacheBlock(bufIndex++);
+                offset = 0;
+            }
+            int nbytes = (int)Math.min(len, (BUFFER_SIZE - offset));
+            dist.write(buf, offset, nbytes);
+            buf = null;
+            len -= nbytes;
+            flushPos += nbytes;
+        }
+    }
+
+	@Override
+	public void shallowClose() throws IOException {
+
+		if(closed) return;
+		super.close();
+ 		cache.clear();
+ 		cache = null;
+ 		dist = null;
+ 		closed = true;		
+	}
+}
diff --git a/src/pixy/io/MemoryCacheSeekableStream.java b/src/pixy/io/MemoryCacheSeekableStream.java
new file mode 100644
index 0000000..e6fb3d1
--- /dev/null
+++ b/src/pixy/io/MemoryCacheSeekableStream.java
@@ -0,0 +1,238 @@
+/**
+ * This is part of the JAI API
+ */
+package pixy.io;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A subclass of <code>SeekableStream</code> that may be used to wrap
+ * a regular <code>InputStream</code>.  Seeking backwards is supported
+ * by means of an in-memory cache.  For greater efficiency,
+ * <code>FileCacheSeekableStream</code> should be used in
+ * circumstances that allow the creation of a temporary file.
+ *
+ * <p> The <code>mark()</code> and <code>reset()</code> methods are
+ * supported.
+ *
+ * <p><b> This class is not a committed part of the JAI API.  It may
+ * be removed or changed in future releases of JAI.</b>
+ */
+public final class MemoryCacheSeekableStream extends SeekableStream {
+
+    /** The source input stream. */
+    private InputStream src;
+
+    /** Position of first unread byte. */
+    private long pointer = 0;
+
+    /** Log_2 of the sector size. */
+    private static final int SECTOR_SHIFT = 9;
+
+    /** The sector size. */
+    private static final int SECTOR_SIZE = 1 << SECTOR_SHIFT;
+
+    /** A mask to determine the offset within a sector. */
+    private static final int SECTOR_MASK = SECTOR_SIZE - 1;
+
+    /** A Vector of source sectors. */
+    private List<byte[]> data = new ArrayList<byte[]>();
+
+    /** Number of sectors stored. */
+    int sectors = 0;
+
+    /** Number of bytes read. */
+    int length = 0;
+
+    /** True if we've previously reached the end of the source stream */
+    boolean foundEOS = false;
+
+    /**
+     * Constructs a <code>MemoryCacheSeekableStream</code> that takes
+     * its source data from a regular <code>InputStream</code>.
+     * Seeking backwards is supported by means of an in-memory cache.
+     */
+    public MemoryCacheSeekableStream(InputStream src) {
+        this.src = src;
+    }
+
+    /**
+     * Ensures that at least <code>pos</code> bytes are cached,
+     * or the end of the source is reached.  The return value
+     * is equal to the smaller of <code>pos</code> and the
+     * length of the source stream.
+     */
+    private long readUntil(long pos) throws IOException {
+        // We've already got enough data cached
+        if (pos < length) {
+            return pos;
+        }
+        // pos >= length but length isn't getting any bigger, so return it
+        if (foundEOS) {
+            return length;
+        }
+
+        int sector = (int)(pos >> SECTOR_SHIFT);
+
+        // First unread sector
+        int startSector = length >> SECTOR_SHIFT;
+
+        // Read sectors until the desired sector
+        for (int i = startSector; i <= sector; i++) {
+            byte[] buf = new byte[SECTOR_SIZE];
+            data.add(buf);
+            
+            // Read up to SECTOR_SIZE bytes
+            int len = SECTOR_SIZE;
+            int off = 0;
+            while (len > 0) {
+                int nbytes = src.read(buf, off, len);
+                // Found the end-of-stream
+                if (nbytes == -1) {
+                    foundEOS = true;
+                    return length;
+                }
+                off += nbytes;
+                len -= nbytes;
+                
+                // Record new data length
+                length += nbytes;
+            }
+        }
+
+        return length;
+    }
+
+    /**
+     * Returns <code>true</code> since all
+     * <code>MemoryCacheSeekableStream</code> instances support seeking
+     * backwards.
+     */
+    public boolean canSeekBackwards() {
+        return true;
+    }
+
+    /**
+     * Returns the current offset in this file. 
+     *
+     * @return     the offset from the beginning of the file, in bytes,
+     *             at which the next read occurs.
+     */
+    public long getFilePointer() {
+        return pointer;
+    }
+
+    /**
+     * Sets the file-pointer offset, measured from the beginning of this 
+     * file, at which the next read occurs.
+     *
+     * @param      pos   the offset position, measured in bytes from the 
+     *                   beginning of the file, at which to set the file 
+     *                   pointer.
+     * @exception  IOException  if <code>pos</code> is less than 
+     *                          <code>0</code> or if an I/O error occurs.
+     */
+    public void seek(long pos) throws IOException {
+        if (pos < 0) {
+            throw new IOException(PropertyUtil.getString("MemoryCacheSeekableStream0"));
+        }
+        pointer = pos;
+    }
+
+    /**
+     * Reads the next byte of data from the input stream. The value byte is
+     * returned as an <code>int</code> in the range <code>0</code> to
+     * <code>255</code>. If no byte is available because the end of the stream
+     * has been reached, the value <code>-1</code> is returned. This method
+     * blocks until input data is available, the end of the stream is detected,
+     * or an exception is thrown.
+     *
+     * @return     the next byte of data, or <code>-1</code> if the end of the
+     *             stream is reached.
+     */
+    public int read() throws IOException {
+        long next = pointer + 1;
+        long pos = readUntil(next);
+        if (pos >= next) {
+            byte[] buf =
+                data.get((int)(pointer >> SECTOR_SHIFT));
+            return buf[(int)(pointer++ & SECTOR_MASK)] & 0xff;
+        }
+        return -1;
+    }
+
+    /**
+     * Reads up to <code>len</code> bytes of data from the input stream into
+     * an array of bytes.  An attempt is made to read as many as
+     * <code>len</code> bytes, but a smaller number may be read, possibly
+     * zero. The number of bytes actually read is returned as an integer.
+     *
+     * <p> This method blocks until input data is available, end of file is
+     * detected, or an exception is thrown.
+     *
+     * <p> If <code>b</code> is <code>null</code>, a
+     * <code>NullPointerException</code> is thrown.
+     *
+     * <p> If <code>off</code> is negative, or <code>len</code> is negative, or
+     * <code>off+len</code> is greater than the length of the array
+     * <code>b</code>, then an <code>IndexOutOfBoundsException</code> is
+     * thrown.
+     *
+     * <p> If <code>len</code> is zero, then no bytes are read and
+     * <code>0</code> is returned; otherwise, there is an attempt to read at
+     * least one byte. If no byte is available because the stream is at end of
+     * file, the value <code>-1</code> is returned; otherwise, at least one
+     * byte is read and stored into <code>b</code>.
+     *
+     * <p> The first byte read is stored into element <code>b[off]</code>, the
+     * next one into <code>b[off+1]</code>, and so on. The number of bytes read
+     * is, at most, equal to <code>len</code>. Let <i>k</i> be the number of
+     * bytes actually read; these bytes will be stored in elements
+     * <code>b[off]</code> through <code>b[off+</code><i>k</i><code>-1]</code>,
+     * leaving elements <code>b[off+</code><i>k</i><code>]</code> through
+     * <code>b[off+len-1]</code> unaffected.
+     *
+     * <p> In every case, elements <code>b[0]</code> through
+     * <code>b[off]</code> and elements <code>b[off+len]</code> through
+     * <code>b[b.length-1]</code> are unaffected.
+     *
+     * <p> If the first byte cannot be read for any reason other than end of
+     * file, then an <code>IOException</code> is thrown. In particular, an
+     * <code>IOException</code> is thrown if the input stream has been closed.
+     *
+     * @param      b     the buffer into which the data is read.
+     * @param      off   the start offset in array <code>b</code>
+     *                   at which the data is written.
+     * @param      len   the maximum number of bytes to read.
+     * @return     the total number of bytes read into the buffer, or
+     *             <code>-1</code> if there is no more data because the end of
+     *             the stream has been reached.
+     */
+    public int read(byte[] b, int off, int len) throws IOException {
+        if (b == null) {
+            throw new NullPointerException();
+        }
+        if ((off < 0) || (len < 0) || (off + len > b.length)) {
+            throw new IndexOutOfBoundsException();
+        }
+        if (len == 0) {
+            return 0;
+        }
+
+        long pos = readUntil(pointer + len);
+        // End-of-stream
+        if (pos <= pointer) {
+            return -1;
+        }
+
+        byte[] buf = data.get((int)(pointer >> SECTOR_SHIFT));
+        int nbytes = Math.min(len, SECTOR_SIZE - (int)(pointer & SECTOR_MASK));
+        System.arraycopy(buf, (int)(pointer & SECTOR_MASK),
+                         b, off, nbytes);
+        pointer += nbytes;
+        return nbytes;
+    }
+}
diff --git a/src/pixy/io/PeekHeadInputStream.java b/src/pixy/io/PeekHeadInputStream.java
new file mode 100644
index 0000000..66655b1
--- /dev/null
+++ b/src/pixy/io/PeekHeadInputStream.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * PeekHeadInputStream.java
+ *
+ * Who   Date       Description
+ * ====  =========  ==============================================================
+ * WY    26Sep2015  Initial creation
+ */
+
+package pixy.io;
+
+import java.io.*;
+
+import pixy.util.ArrayUtils;
+
+/**
+ * Lightweight stream wrapper which allows to peek a
+ * fixed length of bytes from the current stream head
+ *
+ */
+public class PeekHeadInputStream extends InputStream {
+	/** The source stream. */
+    private InputStream src;
+
+	/**
+	 * Buffer to hold the peeked bytes.
+	 */
+	private byte[] buffer;
+
+	/**
+	 * Current buffer position.
+	 */
+	private int position;
+	
+	private boolean closed;
+
+	/**
+	 * @param bytesToPeek number of bytes to peek
+	 * @param src The source InputStream to use
+	 */
+	public PeekHeadInputStream(InputStream src, int bytesToPeek) {
+		this.src = src;
+		this.buffer = new byte[bytesToPeek];
+		try {
+			IOUtils.readFully(src, buffer);
+		} catch(IOException ex) {
+			throw new RuntimeException("Error while reading bytes into buffer");
+		}
+	}
+	
+	public void close() throws IOException {
+		if(closed) return;
+		buffer = null;
+		src.close();
+		src = null;
+		closed = true;
+	}
+	
+	public void shallowClose() throws IOException {
+		if(closed) return;
+		buffer = null;
+		closed = true;
+	}
+	
+	/**
+	 * Check to make sure that this stream has not been closed
+	 */
+    private  void ensureOpen() throws IOException {
+    	if (closed)
+    		throw new IOException("Stream closed");
+    }
+	
+	public byte[] peek(int len) throws IOException {
+		ensureOpen();
+		if(len <= buffer.length) return ArrayUtils.subArray(buffer, 0, len);
+		throw new IllegalArgumentException("Peek length larger than buffer");
+	}
+
+	@Override
+	public int read() throws IOException {
+		ensureOpen();
+		if (position >= buffer.length)
+			return src.read();
+		else
+			return (buffer[position++]&0xff);
+	}
+	
+	@Override
+	public int read(byte[] b, int off, int len) throws IOException {
+		ensureOpen();
+		if (position >= buffer.length) {
+			return src.read(b, off, len);
+		} else if(position + len > buffer.length) {
+			int bytesAvailable = buffer.length - position;
+			System.arraycopy(buffer, position, b, off, bytesAvailable);
+			position += bytesAvailable;			
+			return bytesAvailable + src.read(b, off + bytesAvailable, len - bytesAvailable);
+		}
+		else {
+			System.arraycopy(buffer, position, b, off, len);
+			position += len;
+			return len;
+		}
+	}
+}
diff --git a/src/pixy/io/PropertyUtil.java b/src/pixy/io/PropertyUtil.java
new file mode 100644
index 0000000..dc1c669
--- /dev/null
+++ b/src/pixy/io/PropertyUtil.java
@@ -0,0 +1,45 @@
+package pixy.io;
+
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.util.PropertyResourceBundle;
+import java.util.ResourceBundle;
+
+public class PropertyUtil
+{
+  private static ResourceBundle b;
+  
+  public static String getString( String key )
+  {
+	  if (b == null) {
+		  b = getBundle();
+	  }
+	  return b.getString(key);
+  }
+  
+  /** Get bundle from .properties files in the current dir. */
+  private static ResourceBundle getBundle()
+  {   
+     ResourceBundle bundle = null;
+     
+     InputStream in = null;
+     
+     try {
+    	 try {
+    		 in = PropertyUtil.class.getResourceAsStream("properties");
+    	 } catch(Exception e1) {}
+     
+    	 if(in == null) {
+    		 in = new FileInputStream("properties");
+    	 }
+    	 if (in != null) {
+    		 bundle = new PropertyResourceBundle(in);
+    		 return bundle;
+    	 }
+     } catch (Exception e) {
+    	 e.printStackTrace();
+     }
+     
+     return null;
+  }
+}
diff --git a/src/pixy/io/RandomAccessInputStream.java b/src/pixy/io/RandomAccessInputStream.java
new file mode 100644
index 0000000..09a53e2
--- /dev/null
+++ b/src/pixy/io/RandomAccessInputStream.java
@@ -0,0 +1,206 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.io;
+
+import java.io.DataInput;
+import java.io.DataInputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Implements a random access input stream
+ * <p>
+ * Based on com.sun.media.jai.codec.SeekableStream.
+ * <p>
+ * To make it flexible, this class and any of its sub-class doesn't close the underlying
+ * stream. It's up to the underlying stream creator to close them. This ensures the actual
+ * stream out-lives the random stream itself in case we need to read more content from the
+ * underlying stream.
+ * <p>
+ * NOTE:  for MemoryCacheRandomAccessInputStream, there is the risk of "over read" in which
+ * more bytes are cached in the buffer than actually needed. In this case, the underlying
+ * stream might not be usable anymore afterwards. 
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/24/2013 
+ */ 
+public abstract class RandomAccessInputStream extends InputStream implements DataInput {	
+    
+    private ReadStrategy strategy = ReadStrategyMM.getInstance();
+
+	 /** The source stream. */
+    protected InputStream src;
+    protected boolean closed;
+    
+    protected RandomAccessInputStream(InputStream src) {
+    	this.src = src;
+    }
+    
+    /**
+     * Closes the RandomAccessInputStream and it's underlying stream
+     * @throws IOException
+     */
+    public abstract void shallowClose() throws IOException;
+   
+    /**
+     * Check to make sure that this stream has not been closed
+     */
+    protected  void ensureOpen() throws IOException {
+    	if (closed)
+    		throw new IOException("Stream closed");
+    }
+    
+    protected void finalize() throws Throwable {
+		super.finalize();
+		close();
+	}
+    
+	public short getEndian() {
+    	return strategy instanceof ReadStrategyMM?IOUtils.BIG_ENDIAN:IOUtils.LITTLE_ENDIAN;
+    }
+	
+	public abstract long getStreamPointer();
+	
+	public abstract int read() throws IOException;
+	
+	public abstract int read(byte[] b, int off, int len) throws IOException;
+
+	public final boolean readBoolean() throws IOException {
+		int ch = this.read();
+		if (ch < 0)
+		    throw new EOFException();
+		return (ch != 0);
+	}
+	
+	public final byte readByte() throws IOException {
+	    int ch = this.read();
+		if (ch < 0)
+		   throw new EOFException();
+		return (byte)ch;
+	}
+	
+	public final char readChar() throws IOException {
+		return (char)(readShort()&0xffff);
+	}
+
+	public final double readDouble() throws IOException {
+		return Double.longBitsToDouble(readLong());
+	}
+	
+	public final float readFloat() throws IOException {
+		return Float.intBitsToFloat(readInt());
+	}	
+	
+    public final void readFully(byte[] b) throws IOException {
+		readFully(b, 0, b.length);
+	}
+
+    public final void readFully(byte[] b, int off, int len) throws IOException {
+		int n = 0;
+		do {
+			int count = this.read(b, off + n, len - n);
+		    if (count < 0)
+		        throw new EOFException();
+		    n += count;
+		} while (n < len);
+	}
+
+	public final int readInt() throws IOException {
+		byte[] buf = new byte[4];
+        readFully(buf);
+    	return strategy.readInt(buf, 0);
+	}
+
+	@Deprecated
+	public final String readLine() throws IOException {
+		throw new UnsupportedOperationException(
+			"readLine is not supported by RandomAccessInputStream."
+		);
+	}
+
+	public final long readLong() throws IOException {
+		byte[] buf = new byte[8];
+        readFully(buf);
+    	return strategy.readLong(buf, 0);
+	}
+
+	public final float readS15Fixed16Number() throws IOException {
+		byte[] buf = new byte[4];
+        readFully(buf);
+		return strategy.readS15Fixed16Number(buf, 0);
+	}
+
+	public final short readShort() throws IOException {
+		byte[] buf = new byte[2];
+        readFully(buf);
+    	return strategy.readShort(buf, 0);
+	}
+
+	public final float readU16Fixed16Number() throws IOException {
+		byte[] buf = new byte[4];
+        readFully(buf);
+		return strategy.readU16Fixed16Number(buf, 0);
+	}
+
+	public final float readU8Fixed8Number() throws IOException {
+		byte[] buf = new byte[2];
+        readFully(buf);
+		return strategy.readU8Fixed8Number(buf, 0);
+	}
+	
+	public final int readUnsignedByte() throws IOException {
+		int ch = this.read();
+		if (ch < 0)
+		   throw new EOFException();
+	    return ch;
+	}
+	
+	public final long readUnsignedInt() throws IOException {
+		return readInt()&0xffffffffL;
+	}
+
+	public final int readUnsignedShort() throws IOException {
+		return readShort()&0xffff;
+	}
+
+	/**
+	 *  Due to the current implementation, writeUTF and readUTF are the
+	 *  only methods which are machine or byte sequence independent as
+	 *  they are actually both Motorola byte sequence under the hood.
+	 *  
+	 *  Whereas the following static method is byte sequence dependent
+	 *  as it calls readUnsignedShort of RandomAccessInputStream.
+	 *  
+	 *  <code>DataInputStream.readUTF(this)</code>;
+	 */
+	public final String readUTF() throws IOException {
+		return new DataInputStream(this).readUTF();	
+	} 
+	
+	public abstract void seek(long loc) throws IOException;
+	
+	public void setReadStrategy(ReadStrategy strategy) {
+		this.strategy = strategy;
+	}	
+	
+	public int skipBytes(int n) throws IOException {
+		if (n <= 0) {
+			return 0;
+		}
+		return (int)skip(n);
+	}
+}
diff --git a/src/pixy/io/RandomAccessOutputStream.java b/src/pixy/io/RandomAccessOutputStream.java
new file mode 100644
index 0000000..2859f6e
--- /dev/null
+++ b/src/pixy/io/RandomAccessOutputStream.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * RandomAccessOutputStream.java
+ *
+ * Who   Date       Description
+ * ====  =======    =================================================
+ * WY    07Apr2015  Removed flush(), move it's function to close()
+ */
+
+package pixy.io;
+
+import java.io.DataOutput;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Based on javax.imageio.stream.MemoryCache.java.
+ * * <p>
+ * To make it flexible, this class and any of its sub-class doesn't close the underlying
+ * stream. It's up to the underlying stream creator to close them. This ensures the actual
+ * stream out-lives the random stream itself in case we need to write more content to the
+ * underlying stream.
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/29/2013
+ */
+public abstract class RandomAccessOutputStream extends OutputStream implements DataOutput {
+
+	private WriteStrategy strategy = WriteStrategyMM.getInstance();
+	
+	/** The destination stream. */
+	protected OutputStream dist;
+	protected boolean closed;
+	
+	protected RandomAccessOutputStream(OutputStream dist) {
+		this.dist = dist;
+	}
+	
+	public void close() throws IOException {
+		long flushPos = getFlushPos();
+		long length = getLength();
+		
+		if(flushPos < length) {
+			seek(flushPos);
+			writeToStream(length - flushPos);
+		}
+	}
+	
+	/**
+     * Closes the RandomAccessInputStream and it's underlying stream
+     * @throws IOException
+     */
+    public abstract void shallowClose() throws IOException;
+    
+    /**
+     * Check to make sure that this stream has not been closed
+     */
+    protected  void ensureOpen() throws IOException {
+    	if (closed)
+    		throw new IOException("Stream closed");
+    }
+	
+	public abstract void disposeBefore(long pos) throws IOException;
+	
+	protected void finalize() throws Throwable {
+		super.finalize();
+		close();
+	}
+		
+	public short getEndian() {
+		return strategy instanceof WriteStrategyMM?IOUtils.BIG_ENDIAN:IOUtils.LITTLE_ENDIAN;
+	}
+	
+	public abstract long getFlushPos();
+	
+	/**
+	 * Returns the total length of data that has been cached,
+	 * regardless of whether any early blocks have been disposed.
+	 * This value will only ever increase. 
+	 * @throws IOException 
+	 */
+	public abstract long getLength();
+	
+	/**
+	 * @return the current stream position
+	 * @throws IOException 
+	 */
+	public abstract long getStreamPointer();	
+	
+	/** Reset this stream to be used again */
+	public abstract void reset();
+	
+	public abstract void seek(long pos) throws IOException;
+	
+	public void setWriteStrategy(WriteStrategy strategy) 
+	{
+		this.strategy = strategy;
+	}
+	
+	public abstract void write(byte[] b, int off, int len) throws IOException;
+	
+	@Override
+	public abstract void write(int value) throws IOException;
+
+	public final void writeBoolean(boolean value) throws IOException {
+		this.write(value ? 1 : 0);
+	}
+
+	public final void writeByte(int value) throws IOException {
+		this.write(value);
+	}
+
+	public final void writeBytes(String value) throws IOException {
+		new DataOutputStream(this).writeBytes(value);
+	}
+
+	public final void writeChar(int value) throws IOException {
+		this.writeShort(value);
+	}
+
+	public final void writeChars(String value) throws IOException {
+		int len = value.length();
+		
+		for (int i = 0 ; i < len ; i++) {
+			int v = value.charAt(i);
+		    this.writeShort(v);
+		}
+	}
+
+	public final void writeDouble(double value) throws IOException {
+		 writeLong(Double.doubleToLongBits(value));
+	}
+
+	public final void writeFloat(float value) throws IOException {
+		 writeInt(Float.floatToIntBits(value));
+	}
+
+	public final void writeInt(int value) throws IOException {
+		byte[] buf = new byte[4];
+		strategy.writeInt(buf, 0, value);
+		this.write(buf, 0, 4);
+	}
+	
+	public final void writeLong(long value) throws IOException {
+		byte[] buf = new byte[8];
+		strategy.writeLong(buf, 0, value);
+		this.write(buf, 0, 8);
+	}
+
+	public final void writeS15Fixed16Number(float value) throws IOException {
+		byte[] buf = new byte[4];
+		strategy.writeS15Fixed16Number(buf, 0, value);
+		this.write(buf, 0, 4);
+	}
+	
+	public final void writeShort(int value) throws IOException {
+		byte[] buf = new byte[2];
+		strategy.writeShort(buf, 0, value);
+		this.write(buf, 0, 2);
+	} 
+		
+	public abstract void writeToStream(long len) throws IOException;
+
+	public final void writeU16Fixed16Number(float value) throws IOException {
+		byte[] buf = new byte[4];
+		strategy.writeU16Fixed16Number(buf, 0, value);
+		this.write(buf, 0, 4);
+	}
+	
+	public final void writeU8Fixed8Number(float value) throws IOException {
+		byte[] buf = new byte[2];
+		strategy.writeU8Fixed8Number(buf, 0, value);
+		this.write(buf, 0, 2);
+	}
+	
+	public final void writeUTF(String value) throws IOException {
+		new DataOutputStream(this).writeUTF(value);
+	}
+}
diff --git a/src/pixy/io/ReadStrategy.java b/src/pixy/io/ReadStrategy.java
new file mode 100644
index 0000000..0600781
--- /dev/null
+++ b/src/pixy/io/ReadStrategy.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.io;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 12/27/2012
+ */
+public interface ReadStrategy {
+	//
+	public int readInt(byte[] buf, int start_idx);
+	public int readInt(InputStream is) throws IOException;
+	public long readLong(byte[] buf, int start_idx);
+	public long readLong(InputStream is) throws IOException;
+	public float readS15Fixed16Number(byte[] buf, int start_idx);
+	public float readS15Fixed16Number(InputStream is) throws IOException;
+	public short readShort(byte[] buf, int start_idx);
+	public short readShort(InputStream is) throws IOException;
+	public float readU16Fixed16Number(byte[] buf, int start_idx);
+	public float readU16Fixed16Number(InputStream is) throws IOException;
+    public float readU8Fixed8Number(byte[] buf, int start_idx);
+	public float readU8Fixed8Number(InputStream is) throws IOException;
+	public long readUnsignedInt(byte[] buf, int start_idx);
+    public long readUnsignedInt(InputStream is) throws IOException;
+    public int readUnsignedShort(byte[] buf, int start_idx);
+    public int readUnsignedShort(InputStream is) throws IOException;
+}
diff --git a/src/pixy/io/ReadStrategyII.java b/src/pixy/io/ReadStrategyII.java
new file mode 100644
index 0000000..dc32e24
--- /dev/null
+++ b/src/pixy/io/ReadStrategyII.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.io;
+
+import java.io.InputStream;
+import java.io.IOException;
+
+/**
+ * Read strategy for Intel byte order LITTLE-ENDIAN stream.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 12/27/2012
+ */
+public class ReadStrategyII implements ReadStrategy {
+
+	 private static final ReadStrategyII instance = new ReadStrategyII();
+	 
+	 public static ReadStrategyII getInstance() 
+	 {
+		 return instance;
+	 }
+	 
+	 private ReadStrategyII(){}
+	 
+	 public int readInt(byte[] buf, int start_idx)
+	 { 
+		 return ((buf[start_idx++]&0xff)|((buf[start_idx++]&0xff)<<8)|
+			               ((buf[start_idx++]&0xff)<<16)|((buf[start_idx++]&0xff)<<24));
+	 }
+	 
+	 public int readInt(InputStream is) throws IOException
+	 {
+		 byte[] buf = new byte[4];
+		 IOUtils.readFully(is, buf);
+		 
+		 return (((buf[3]&0xff)<<24)|((buf[2]&0xff)<<16)|((buf[1]&0xff)<<8)|(buf[0]&0xff));
+	 }
+	 
+	 public long readLong(byte[] buf, int start_idx) 
+     {    	 
+         return ((buf[start_idx++]&0xffL)|(((buf[start_idx++]&0xffL)<<8)|((buf[start_idx++]&0xffL)<<16)|
+        		 ((buf[start_idx++]&0xffL)<<24)|((buf[start_idx++]&0xffL)<<32)|((buf[start_idx++]&0xffL)<<40)|
+        		   ((buf[start_idx++]&0xffL)<<48)|(buf[start_idx]&0xffL)<<56));
+     }
+
+	 public long readLong(InputStream is) throws IOException 
+     {
+    	 byte[] buf = new byte[8];
+		 IOUtils.readFully(is, buf);
+		 
+         return (((buf[7]&0xffL)<<56)|((buf[6]&0xffL)<<48)|
+                                ((buf[5]&0xffL)<<40)|((buf[4]&0xffL)<<32)|((buf[3]&0xffL)<<24)|
+                                  ((buf[2]&0xffL)<<16)|((buf[1]&0xffL)<<8)|(buf[0]&0xffL));
+     }
+	 
+	 public float readS15Fixed16Number(byte[] buf, int start_idx)
+	 { 
+		 short s15 = (short)((buf[start_idx++]&0xff)|((buf[start_idx++]&0xff)<<8));
+		 int fixed16 = ((buf[start_idx++]&0xff)|((buf[start_idx]&0xff)<<8));
+		 
+		 return s15 + fixed16/65536.0f;
+	 }
+
+	 public float readS15Fixed16Number(InputStream is) throws IOException
+	 { 		
+		 byte[] buf = new byte[4];
+		 IOUtils.readFully(is, buf);
+		 
+		 short s15 = (short)((buf[0]&0xff)|((buf[1]&0xff)<<8));
+		 int fixed16 = ((buf[2]&0xff)|((buf[3]&0xff)<<8));
+		 
+		 return s15 + fixed16/65536.0f;	
+	 }
+	 
+	 public short readShort(byte[] buf, int start_idx)
+	 { 
+		 return (short)((buf[start_idx++]&0xff)|((buf[start_idx]&0xff)<<8));
+	 }
+
+	 public short readShort(InputStream is) throws IOException
+	 { 
+		 byte[] buf = new byte[2];
+		 IOUtils.readFully(is, buf);
+		
+		 return (short)(((buf[1]&0xff)<<8)|(buf[0]&0xff));
+	 }
+	 
+	 public float readU16Fixed16Number(byte[] buf, int start_idx)
+	 { 
+		 int u16 = ((buf[start_idx++]&0xff)|((buf[start_idx++]&0xff)<<8));
+		 int fixed16 = ((buf[start_idx++]&0xff)|((buf[start_idx]&0xff)<<8));
+		 
+		 return u16 + fixed16/65536.0f;
+	 }
+
+	 public float readU16Fixed16Number(InputStream is) throws IOException
+	 { 
+		 byte[] buf = new byte[4];
+		 IOUtils.readFully(is, buf);
+		 
+		 int u16 = ((buf[0]&0xff)|((buf[1]&0xff)<<8));
+		 int fixed16 = ((buf[2]&0xff)|((buf[3]&0xff)<<8));
+		 
+		 return u16 + fixed16/65536.0f;	
+	 }
+
+	 public float readU8Fixed8Number(byte[] buf, int start_idx)
+	 { 
+		 int u8 = (buf[start_idx++]&0xff);
+		 int fixed8 = (buf[start_idx]&0xff);
+		 
+		 return u8 + fixed8/256.0f;
+	 }
+
+	 public float readU8Fixed8Number(InputStream is) throws IOException
+	 { 
+		 byte[] buf = new byte[2];
+		 IOUtils.readFully(is, buf);
+		 
+		 int u8 = (buf[0]&0xff);
+		 int fixed8 = (buf[1]&0xff);
+		 
+		 return u8 + fixed8/256.0f;	
+	 }
+
+	 public long readUnsignedInt(byte[] buf, int start_idx)
+	 { 
+		 return ((buf[start_idx++]&0xff)|((buf[start_idx++]&0xff)<<8)|
+			         ((buf[start_idx++]&0xff)<<16)|((buf[start_idx++]&0xff)<<24))& 0xffffffffL;
+	 }
+
+	 public long readUnsignedInt(InputStream is) throws IOException
+	 {
+		 byte[] buf = new byte[4];
+		 IOUtils.readFully(is, buf);
+		 
+		 return (((buf[3]&0xff)<<24)|((buf[2]&0xff)<<16)|
+			                ((buf[1]&0xff)<<8)|(buf[0]&0xff))& 0xffffffffL;
+	 }
+	 
+	 public int readUnsignedShort(byte[] buf, int start_idx)
+	 { 
+		 return ((buf[start_idx++]&0xff)|((buf[start_idx]&0xff)<<8));
+	 }
+	 
+	 public int readUnsignedShort(InputStream is) throws IOException
+	 { 
+		 byte[] buf = new byte[2];
+		 IOUtils.readFully(is, buf);
+		
+		 return (((buf[1]&0xff)<<8)|(buf[0]&0xff));
+	 }    	 
+}
diff --git a/src/pixy/io/ReadStrategyMM.java b/src/pixy/io/ReadStrategyMM.java
new file mode 100644
index 0000000..6df6800
--- /dev/null
+++ b/src/pixy/io/ReadStrategyMM.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.io;
+
+import java.io.InputStream;
+import java.io.IOException;
+
+/**
+ * Read strategy for Motorola byte order BIG-ENDIAN stream.
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 12/27/2012
+ */
+public class ReadStrategyMM implements ReadStrategy {
+
+	 private static final ReadStrategyMM instance = new ReadStrategyMM();
+	 
+	 public static ReadStrategyMM getInstance() 
+	 {
+		 return instance;
+	 }
+	 
+	 private ReadStrategyMM(){}
+	 
+	 public int readInt(byte[] buf, int start_idx)
+	 { 
+		return (((buf[start_idx++]&0xff)<<24)|((buf[start_idx++]&0xff)<<16)|
+			               ((buf[start_idx++]&0xff)<<8)|(buf[start_idx++]&0xff));
+	 }
+	 
+	 public int readInt(InputStream is) throws IOException
+	 {
+		 byte[] buf = new byte[4];
+		 IOUtils.readFully(is, buf);
+		 
+		 return (((buf[0]&0xff)<<24)|((buf[1]&0xff)<<16)|((buf[2]&0xff)<<8)|(buf[3]&0xff));
+	 }
+	 
+	 public long readLong(byte[] buf, int start_idx)
+     {		 
+         return (((buf[start_idx++]&0xffL)<<56)|((buf[start_idx++]&0xffL)<<48)|
+                   ((buf[start_idx++]&0xffL)<<40)|((buf[start_idx++]&0xffL)<<32)|((buf[start_idx++]&0xffL)<<24)|
+                     ((buf[start_idx++]&0xffL)<<16)|((buf[start_idx++]&0xffL)<<8)|(buf[start_idx]&0xffL));
+     }
+
+	 public long readLong(InputStream is) throws IOException
+     {
+         byte[] buf = new byte[8];
+		 IOUtils.readFully(is, buf);
+		 
+         return (((buf[0]&0xffL)<<56)|((buf[1]&0xffL)<<48)|
+                    ((buf[2]&0xffL)<<40)|((buf[3]&0xffL)<<32)|((buf[4]&0xffL)<<24)|
+                      ((buf[5]&0xffL)<<16)|((buf[6]&0xffL)<<8)|(buf[7]&0xffL));
+     }
+	 
+	 public float readS15Fixed16Number(byte[] buf, int start_idx)
+	 { 
+		 short s15 = (short)(((buf[start_idx++]&0xff)<<8)|(buf[start_idx++]&0xff));
+		 int fixed16 = (((buf[start_idx++]&0xff)<<8)|(buf[start_idx]&0xff));
+		 
+		 return s15 + fixed16/65536.0f;
+	 }
+
+	 public float readS15Fixed16Number(InputStream is) throws IOException
+	 { 		
+		 byte[] buf = new byte[4];
+		 IOUtils.readFully(is, buf);
+		 
+		 short s15 = (short)((buf[1]&0xff)|((buf[0]&0xff)<<8));
+		 int fixed16 = ((buf[3]&0xff)|((buf[2]&0xff)<<8));
+		 
+		 return s15 + fixed16/65536.0f;	
+	 }
+	 
+	 public short readShort(byte[] buf, int start_idx)
+	 { 
+		return (short)(((buf[start_idx++]&0xff)<<8)|(buf[start_idx]&0xff));
+	 }
+
+	 public short readShort(InputStream is) throws IOException
+	 { 
+		byte[] buf = new byte[2];
+		IOUtils.readFully(is, buf);
+		
+		return (short)(((buf[0]&0xff)<<8)|(buf[1]&0xff));
+	 }
+	 
+	 public float readU16Fixed16Number(byte[] buf, int start_idx)
+	 { 
+		 int u16 = (((buf[start_idx++]&0xff)<<8)|(buf[start_idx++]&0xff));
+		 int fixed16 = (((buf[start_idx++]&0xff)<<8)|(buf[start_idx]&0xff));
+		 
+		 return u16 + fixed16/65536.0f;
+	 }
+
+	 public float readU16Fixed16Number(InputStream is) throws IOException
+	 { 
+		 byte[] buf = new byte[4];
+		 IOUtils.readFully(is, buf);
+		 
+		 int u16 = ((buf[1]&0xff)|((buf[0]&0xff)<<8));
+		 int fixed16 = ((buf[3]&0xff)|((buf[2]&0xff)<<8));
+		 
+		 return u16 + fixed16/65536.0f;	
+	 }
+
+	 public float readU8Fixed8Number(byte[] buf, int start_idx)
+	 { 
+		 int u8 = (buf[start_idx++]&0xff);
+		 int fixed8 = (buf[start_idx]&0xff);
+		 
+		 return u8 + fixed8/256.0f;
+	 }
+
+	 public float readU8Fixed8Number(InputStream is) throws IOException
+	 { 
+		 byte[] buf = new byte[2];
+		 IOUtils.readFully(is, buf);
+		 
+		 int u8 = (buf[0]&0xff);
+		 int fixed8 = (buf[1]&0xff);
+		 
+		 return u8 + fixed8/256.0f;	
+	 }
+
+	 public long readUnsignedInt(byte[] buf, int start_idx)
+	 { 
+		 return (((buf[start_idx++]&0xff)<<24)|((buf[start_idx++]&0xff)<<16)|
+			                 ((buf[start_idx++]&0xff)<<8)|(buf[start_idx++]&0xff))& 0xffffffffL;
+	 }
+
+	 public long readUnsignedInt(InputStream is) throws IOException
+	 {
+		 byte[] buf = new byte[4];
+		 IOUtils.readFully(is, buf);
+		 
+		 return (((buf[0]&0xff)<<24)|((buf[1]&0xff)<<16)|
+			                    ((buf[2]&0xff)<<8)|(buf[3]&0xff))& 0xffffffffL;
+	 }
+	 
+	 public int readUnsignedShort(byte[] buf, int start_idx)
+	 { 
+		 return (((buf[start_idx++]&0xff)<<8)|(buf[start_idx]&0xff));
+	 }
+     
+     public int readUnsignedShort(InputStream is) throws IOException
+	 { 
+		 byte[] buf = new byte[2];
+		 IOUtils.readFully(is, buf);
+		
+		 return (((buf[0]&0xff)<<8)|(buf[1]&0xff));
+	 }
+}
diff --git a/src/pixy/io/SeekableStream.java b/src/pixy/io/SeekableStream.java
new file mode 100644
index 0000000..6573be3
--- /dev/null
+++ b/src/pixy/io/SeekableStream.java
@@ -0,0 +1,917 @@
+/**
+ * This is part of the JAI API
+ */
+package pixy.io;
+
+import java.io.DataInput;
+import java.io.DataInputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UTFDataFormatException;
+
+/**
+ * An abstract subclass of <code>java.io.InputStream</code> that allows seeking
+ * within the input, similar to the <code>RandomAccessFile</code> class.
+ * Additionally, the <code>DataInput</code> interface is supported and extended
+ * to include support for little-endian representations of fundamental data
+ * types.
+ *
+ * <p> In addition to the familiar methods from <code>InputStream</code>, the
+ * methods <code>getFilePointer()</code>, <code>seek()</code>, are defined as in
+ * the <code>RandomAccessFile</code> class.  The <code>canSeekBackwards()</code>
+ * method will return <code>true</code> if it is permissible to seek to a
+ * position earlier in the stream than the current value of
+ * <code>getFilePointer()</code>.  Some subclasses of
+ * <code>SeekableStream</code> guarantee the ability to seek backwards while
+ * others may not offer this feature in the interest of providing greater
+ * efficiency for those users who do not require it.
+ * 
+ * <p> The <code>DataInput</code> interface is supported as well.  This included
+ * the <code>skipBytes()</code> and <code>readFully()</code> methods and a
+ * variety of <code>read</code> methods for various data types.
+ *
+ * <p> A number of concrete subclasses of <code>SeekableStream</code> are
+ * supplied in the <code>com.sun.media.jai.codec</code> package.
+ * 
+ * <p> Three classes are provided for the purpose of adapting a standard
+ * <code>InputStream</code> to the <code>SeekableStream</code> interface.
+ * <code>ForwardSeekableStream</code> does not allows seeking backwards, but is
+ * inexpensive to use.  <code>FileCacheSeekableStream</code> maintains a copy of
+ * all of the data read from the input in a temporary file; this file will be
+ * discarded automatically when the <code>FileSeekableStream</code> is
+ * finalized, or when the JVM exits normally.
+ * <code>FileCacheSeekableStream</code> is intended to be reasonably efficient
+ * apart from the unavoidable use of disk space.  In circumstances where the
+ * creation of a temporary file is not possible,
+ * <code>MemoryCacheSeekableStream</code> may be used.
+ * <code>MemoryCacheSeekableStream</code> creates a potentially large in-memory
+ * buffer to store the stream data and so should be avoided when possible.
+ *
+ * <p> The <code>FileSeekableStream</code> class wraps a <code>File</code> or
+ * <code>RandomAccessFile</code>. It forwards requests to the real underlying
+ * file.  It performs a limited amount of caching in order to avoid excessive
+ * I/O costs.
+ *
+ * <p> The <code>SegmentedSeekableStream</code> class performs a different sort
+ * of function.  It creates a <code>SeekableStream</code> from another
+ * <code>SeekableStream</code> by selecting a series of portions or "segments".
+ * Each segment starts at a specified location within the source
+ * <code>SeekableStream</code> and extends for a specified number of bytes.  The
+ * <code>StreamSegmentMapper</code> interface and <code>StreamSegment</code>
+ * class may be used to compute the segment positions dynamically.
+ *
+ * <p> A convenience methods, <code>wrapInputStream</code> is provided to
+ * construct a suitable <code>SeekableStream</code> instance whose data is
+ * supplied by a given <code>InputStream</code>.  The caller, by means of the
+ * <code>canSeekBackwards</code> parameter, determines whether support for
+ * seeking backwards is required.
+ * 
+ */
+public abstract class SeekableStream extends InputStream implements DataInput {
+
+    /**
+     * Returns a <code>SeekableStream</code> that will read from a 
+     * given <code>InputStream</code>, optionally including support
+     * for seeking backwards.  This is a convenience method that
+     * avoids the need to instantiate specific subclasses of 
+     * <code>SeekableStream</code> depending on the current security
+     * model.
+     *
+     * @param is An <code>InputStream</code>.
+     * @param canSeekBackwards <code>true</code> if the ability to seek
+     *        backwards in the output is required.
+     * @return An instance of <code>SeekableStream</code>.
+     */
+    public static SeekableStream wrapInputStream(InputStream is,
+                                                 boolean canSeekBackwards) {
+        SeekableStream stream = null;
+
+        if (canSeekBackwards) {
+            try {
+                stream = new FileCacheSeekableStream(is);
+            } catch (Exception e) {
+                stream = new MemoryCacheSeekableStream(is);
+            }
+        } else {
+            stream = new ForwardSeekableStream(is);
+        }
+        return stream;
+    }
+
+    // Methods from InputStream
+
+    /**
+     * Reads the next byte of data from the input stream. The value byte is
+     * returned as an <code>int</code> in the range <code>0</code> to
+     * <code>255</code>. If no byte is available because the end of the stream
+     * has been reached, the value <code>-1</code> is returned. This method
+     * blocks until input data is available, the end of the stream is detected,
+     * or an exception is thrown.
+     *
+     * <p> A subclass must provide an implementation of this method.
+     *
+     * @return     the next byte of data, or <code>-1</code> if the end of the
+     *             stream is reached.
+     * @exception  IOException  if an I/O error occurs.
+     */
+    public abstract int read() throws IOException;
+
+    /**
+     * Reads up to <code>len</code> bytes of data from the input stream into
+     * an array of bytes.  An attempt is made to read as many as
+     * <code>len</code> bytes, but a smaller number may be read, possibly
+     * zero. The number of bytes actually read is returned as an integer.
+     *
+     * <p> This method blocks until input data is available, end of stream is
+     * detected, or an exception is thrown.
+     *
+     * <p> If <code>b</code> is <code>null</code>, a
+     * <code>NullPointerException</code> is thrown.
+     *
+     * <p> If <code>off</code> is negative, or <code>len</code> is negative, or
+     * <code>off+len</code> is greater than the length of the array
+     * <code>b</code>, then an <code>IndexOutOfBoundsException</code> is
+     * thrown.
+     *
+     * <p> If <code>len</code> is zero, then no bytes are read and
+     * <code>0</code> is returned; otherwise, there is an attempt to read at
+     * least one byte. If no byte is available because the stream is at end of
+     * stream, the value <code>-1</code> is returned; otherwise, at least one
+     * byte is read and stored into <code>b</code>.
+     *
+     * <p> The first byte read is stored into element <code>b[off]</code>, the
+     * next one into <code>b[off+1]</code>, and so on. The number of bytes read
+     * is, at most, equal to <code>len</code>. Let <i>k</i> be the number of
+     * bytes actually read; these bytes will be stored in elements
+     * <code>b[off]</code> through <code>b[off+</code><i>k</i><code>-1]</code>,
+     * leaving elements <code>b[off+</code><i>k</i><code>]</code> through
+     * <code>b[off+len-1]</code> unaffected.
+     *
+     * <p> In every case, elements <code>b[0]</code> through
+     * <code>b[off]</code> and elements <code>b[off+len]</code> through
+     * <code>b[b.length-1]</code> are unaffected.
+     *
+     * <p> If the first byte cannot be read for any reason other than end of
+     * stream, then an <code>IOException</code> is thrown. In particular, an
+     * <code>IOException</code> is thrown if the input stream has been closed.
+     *
+     * <p> A subclass must provide an implementation of this method.
+     *
+     * @param      b     the buffer into which the data is read.
+     * @param      off   the start offset in array <code>b</code>
+     *                   at which the data is written.
+     * @param      len   the maximum number of bytes to read.
+     * @return     the total number of bytes read into the buffer, or
+     *             <code>-1</code> if there is no more data because the end of
+     *             the stream has been reached.
+     * @exception  IOException  if an I/O error occurs.
+     */
+    public abstract int read(byte[] b, int off, int len) throws IOException;
+    
+    // Implemented in InputStream:
+    //
+    // public int read(byte[] b) throws IOException {
+    // public long skip(long n) throws IOException
+    // public int available) throws IOException
+    // public void close() throws IOException;
+
+    /** Marked position */
+    protected long markPos = -1L;
+
+    /**
+     * Marks the current file position for later return using
+     * the <code>reset()</code> method.
+     */
+    public synchronized void mark(int readLimit) {
+        try {
+            markPos = getFilePointer();
+        } catch (IOException e) {
+            markPos = -1L;
+        }
+    }
+
+    /**
+     * Returns the file position to its position at the time of
+     * the immediately previous call to the <code>mark()</code>
+     * method.
+     */
+    public synchronized void reset() throws IOException {
+        if (markPos != -1) {
+            seek(markPos);
+        }
+    }
+
+    /**
+     * Returns <code>true</code> if marking is supported.
+     * Marking is automatically supported for <code>SeekableStream</code>
+     * subclasses that support seeking backeards.  Subclasses that do
+     * not support seeking backwards but do support marking must override
+     * this method.
+     */
+    public boolean markSupported() {
+        return canSeekBackwards();
+    }
+
+    /**
+     * Returns <code>true</code> if this object supports calls to 
+     * <code>seek(pos)</code> with an offset <code>pos</code> smaller
+     * than the current offset, as returned by <code>getFilePointer</code>.
+     */
+    public boolean canSeekBackwards() {
+        return false;
+    }
+
+    /**
+     * Returns the current offset in this stream.
+     *
+     * @return     the offset from the beginning of the stream, in bytes,
+     *             at which the next read occurs.
+     * @exception  IOException  if an I/O error occurs.
+     */
+    public abstract long getFilePointer() throws IOException;
+
+    /**
+     * Sets the offset, measured from the beginning of this 
+     * stream, at which the next read occurs.
+     *
+     * <p> If <code>canSeekBackwards()</code> returns <code>false</code>,
+     * then setting <code>pos</code> to an offset smaller than
+     * the current value of <code>getFilePointer()</code> will have
+     * no effect.
+     *
+     * @param      pos   the offset position, measured in bytes from the 
+     *                   beginning of the stream, at which to set the stream 
+     *                   pointer.
+     * @exception  IOException  if <code>pos</code> is less than 
+     *                          <code>0</code> or if an I/O error occurs.
+     */
+    public abstract void seek(long pos) throws IOException;
+
+    // Methods from RandomAccessFile
+
+    /**
+     * Reads <code>b.length</code> bytes from this stream into the byte 
+     * array, starting at the current stream pointer. This method reads 
+     * repeatedly from the stream until the requested number of bytes are 
+     * read. This method blocks until the requested number of bytes are 
+     * read, the end of the stream is detected, or an exception is thrown. 
+     *
+     * @param      b   the buffer into which the data is read.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *               all the bytes.
+     * @exception  IOException   if an I/O error occurs.       
+     */
+    public final void readFully(byte[] b) throws IOException {
+        readFully(b, 0, b.length);
+    }
+
+    /**
+     * Reads exactly <code>len</code> bytes from this stream into the byte 
+     * array, starting at the current stream pointer. This method reads 
+     * repeatedly from the stream until the requested number of bytes are 
+     * read. This method blocks until the requested number of bytes are 
+     * read, the end of the stream is detected, or an exception is thrown. 
+     *
+     * @param      b     the buffer into which the data is read.
+     * @param      off   the start offset of the data.
+     * @param      len   the number of bytes to read.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *               all the bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final void readFully(byte[] b, int off, int len)
+        throws IOException {
+        int n = 0;
+        do {
+        	int count = this.read(b, off + n, len - n);
+        	if (count < 0)
+        		throw new EOFException();
+        	n += count;
+        } while (n < len);
+    }
+
+    // Methods from DataInput, plus little-endian versions
+
+    /**
+     * Attempts to skip over <code>n</code> bytes of input discarding the 
+     * skipped bytes. 
+     * <p>
+     * 
+     * This method may skip over some smaller number of bytes, possibly zero. 
+     * This may result from any of a number of conditions; reaching end of 
+     * stream before <code>n</code> bytes have been skipped is only one 
+     * possibility. This method never throws an <code>EOFException</code>. 
+     * The actual number of bytes skipped is returned.  If <code>n</code> 
+     * is negative, no bytes are skipped.
+     *
+     * @param      n   the number of bytes to be skipped.
+     * @return     the actual number of bytes skipped.
+     * @exception  IOException  if an I/O error occurs.
+     */
+    public int skipBytes(int n) throws IOException {
+        if (n <= 0) {
+            return 0;
+        }
+        return (int)skip(n);
+    }
+
+    /**
+     * Reads a <code>boolean</code> from this stream. This method reads a 
+     * single byte from the stream, starting at the current stream pointer. 
+     * A value of <code>0</code> represents 
+     * <code>false</code>. Any other value represents <code>true</code>. 
+     * This method blocks until the byte is read, the end of the stream 
+     * is detected, or an exception is thrown. 
+     *
+     * @return     the <code>boolean</code> value read.
+     * @exception  EOFException  if this stream has reached the end.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final boolean readBoolean() throws IOException {
+    	int ch = this.read();
+    	if (ch < 0)
+    		throw new EOFException();
+    	return (ch != 0);
+    }
+
+    /**
+     * Reads a signed eight-bit value from this stream. This method reads a 
+     * byte from the stream, starting from the current stream pointer. 
+     * If the byte read is <code>b</code>, where 
+     * <code>0&nbsp;&lt;=&nbsp;b&nbsp;&lt;=&nbsp;255</code>, 
+     * then the result is:
+     * <blockquote><pre>
+     *     (byte)(b)
+     * </pre></blockquote>
+     * <p>
+     * This method blocks until the byte is read, the end of the stream 
+     * is detected, or an exception is thrown. 
+     *
+     * @return     the next byte of this stream as a signed eight-bit
+     *             <code>byte</code>.
+     * @exception  EOFException  if this stream has reached the end.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final byte readByte() throws IOException {
+    	int ch = this.read();
+    	if (ch < 0)
+    		throw new EOFException();
+    	return (byte)(ch);
+    }
+
+    /**
+     * Reads an unsigned eight-bit number from this stream. This method reads 
+     * a byte from this stream, starting at the current stream pointer, 
+     * and returns that byte. 
+     * <p>
+     * This method blocks until the byte is read, the end of the stream 
+     * is detected, or an exception is thrown. 
+     *
+     * @return     the next byte of this stream, interpreted as an unsigned
+     *             eight-bit number.
+     * @exception  EOFException  if this stream has reached the end.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final int readUnsignedByte() throws IOException {
+    	int ch = this.read();
+    	if (ch < 0)
+    		throw new EOFException();
+    	return ch;
+    }
+
+    /**
+     * Reads a signed 16-bit number from this stream.
+     * The method reads two 
+     * bytes from this stream, starting at the current stream pointer. 
+     * If the two bytes read, in order, are 
+     * <code>b1</code> and <code>b2</code>, where each of the two values is 
+     * between <code>0</code> and <code>255</code>, inclusive, then the 
+     * result is equal to:
+     * <blockquote><pre>
+     *     (short)((b1 &lt;&lt; 8) | b2)
+     * </pre></blockquote>
+     * <p>
+     * This method blocks until the two bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next two bytes of this stream, interpreted as a signed
+     *             16-bit number.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *               two bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final short readShort() throws IOException {
+    	int ch1 = this.read();
+    	int ch2 = this.read();
+    	if ((ch1 | ch2) < 0)
+    		throw new EOFException();
+    	return (short)((ch1 << 8) + (ch2 << 0));
+    }
+
+    /**
+     * Reads a signed 16-bit number from this stream in little-endian order.
+     * The method reads two 
+     * bytes from this stream, starting at the current stream pointer. 
+     * If the two bytes read, in order, are 
+     * <code>b1</code> and <code>b2</code>, where each of the two values is 
+     * between <code>0</code> and <code>255</code>, inclusive, then the 
+     * result is equal to:
+     * <blockquote><pre>
+     *     (short)((b2 &lt;&lt; 8) | b1)
+     * </pre></blockquote>
+     * <p>
+     * This method blocks until the two bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next two bytes of this stream, interpreted as a signed
+     *             16-bit number.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *               two bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final short readShortLE() throws IOException {
+    	int ch1 = this.read();
+    	int ch2 = this.read();
+    	if ((ch1 | ch2) < 0)
+    		throw new EOFException();
+    	return (short)((ch2 << 8) + (ch1 << 0));
+    }
+
+    /**
+     * Reads an unsigned 16-bit number from this stream. This method reads 
+     * two bytes from the stream, starting at the current stream pointer. 
+     * If the bytes read, in order, are 
+     * <code>b1</code> and <code>b2</code>, where 
+     * <code>0&nbsp;&lt;=&nbsp;b1, b2&nbsp;&lt;=&nbsp;255</code>, 
+     * then the result is equal to:
+     * <blockquote><pre>
+     *     (b1 &lt;&lt; 8) | b2
+     * </pre></blockquote>
+     * <p>
+     * This method blocks until the two bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next two bytes of this stream, interpreted as an
+     *             unsigned 16-bit integer.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *             two bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final int readUnsignedShort() throws IOException {
+    	int ch1 = this.read();
+    	int ch2 = this.read();
+    	if ((ch1 | ch2) < 0)
+    		throw new EOFException();
+    	return (ch1 << 8) + (ch2 << 0);
+    }
+
+    /**
+     * Reads an unsigned 16-bit number from this stream in little-endian order.
+     * This method reads 
+     * two bytes from the stream, starting at the current stream pointer. 
+     * If the bytes read, in order, are 
+     * <code>b1</code> and <code>b2</code>, where 
+     * <code>0&nbsp;&lt;=&nbsp;b1, b2&nbsp;&lt;=&nbsp;255</code>, 
+     * then the result is equal to:
+     * <blockquote><pre>
+     *     (b2 &lt;&lt; 8) | b1
+     * </pre></blockquote>
+     * <p>
+     * This method blocks until the two bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next two bytes of this stream, interpreted as an
+     *             unsigned 16-bit integer.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *               two bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final int readUnsignedShortLE() throws IOException {
+    	int ch1 = this.read();
+    	int ch2 = this.read();
+    	if ((ch1 | ch2) < 0)
+    		throw new EOFException();
+    	return (ch2 << 8) + (ch1 << 0);
+    }
+
+    /**
+     * Reads a Unicode character from this stream. This method reads two
+     * bytes from the stream, starting at the current stream pointer. 
+     * If the bytes read, in order, are 
+     * <code>b1</code> and <code>b2</code>, where 
+     * <code>0&nbsp;&lt;=&nbsp;b1,&nbsp;b2&nbsp;&lt;=&nbsp;255</code>, 
+     * then the result is equal to:
+     * <blockquote><pre>
+     *     (char)((b1 &lt;&lt; 8) | b2)
+     * </pre></blockquote>
+     * <p>
+     * This method blocks until the two bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next two bytes of this stream as a Unicode character.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *               two bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final char readChar() throws IOException {
+    	int ch1 = this.read();
+    	int ch2 = this.read();
+    	if ((ch1 | ch2) < 0)
+    		throw new EOFException();
+    	return (char)((ch1 << 8) + (ch2 << 0));
+    }
+
+    /**
+     * Reads a Unicode character from this stream in little-endian order.
+     * This method reads two
+     * bytes from the stream, starting at the current stream pointer. 
+     * If the bytes read, in order, are 
+     * <code>b1</code> and <code>b2</code>, where 
+     * <code>0&nbsp;&lt;=&nbsp;b1,&nbsp;b2&nbsp;&lt;=&nbsp;255</code>, 
+     * then the result is equal to:
+     * <blockquote><pre>
+     *     (char)((b2 &lt;&lt; 8) | b1)
+     * </pre></blockquote>
+     * <p>
+     * This method blocks until the two bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next two bytes of this stream as a Unicode character.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *               two bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final char readCharLE() throws IOException {
+    	int ch1 = this.read();
+    	int ch2 = this.read();
+    	if ((ch1 | ch2) < 0)
+    		throw new EOFException();
+    	return (char)((ch2 << 8) + (ch1 << 0));
+    }
+
+    /**
+     * Reads a signed 32-bit integer from this stream. This method reads 4 
+     * bytes from the stream, starting at the current stream pointer. 
+     * If the bytes read, in order, are <code>b1</code>,
+     * <code>b2</code>, <code>b3</code>, and <code>b4</code>, where 
+     * <code>0&nbsp;&lt;=&nbsp;b1, b2, b3, b4&nbsp;&lt;=&nbsp;255</code>, 
+     * then the result is equal to:
+     * <blockquote><pre>
+     *     (b1 &lt;&lt; 24) | (b2 &lt;&lt; 16) + (b3 &lt;&lt; 8) + b4
+     * </pre></blockquote>
+     * <p>
+     * This method blocks until the four bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next four bytes of this stream, interpreted as an
+     *             <code>int</code>.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *               four bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final int readInt() throws IOException {
+    	int ch1 = this.read();
+    	int ch2 = this.read();
+    	int ch3 = this.read();
+    	int ch4 = this.read();
+    	if ((ch1 | ch2 | ch3 | ch4) < 0)
+    		throw new EOFException();
+    	return ((ch1 << 24) + (ch2 << 16) + (ch3 << 8) + (ch4 << 0));
+    }
+
+    /**
+     * Reads a signed 32-bit integer from this stream in little-endian order.
+     * This method reads 4 
+     * bytes from the stream, starting at the current stream pointer. 
+     * If the bytes read, in order, are <code>b1</code>,
+     * <code>b2</code>, <code>b3</code>, and <code>b4</code>, where 
+     * <code>0&nbsp;&lt;=&nbsp;b1, b2, b3, b4&nbsp;&lt;=&nbsp;255</code>, 
+     * then the result is equal to:
+     * <blockquote><pre>
+     *     (b4 &lt;&lt; 24) | (b3 &lt;&lt; 16) + (b2 &lt;&lt; 8) + b1
+     * </pre></blockquote>
+     * <p>
+     * This method blocks until the four bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next four bytes of this stream, interpreted as an
+     *             <code>int</code>.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *               four bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final int readIntLE() throws IOException {
+    	int ch1 = this.read();
+    	int ch2 = this.read();
+    	int ch3 = this.read();
+    	int ch4 = this.read();
+    	if ((ch1 | ch2 | ch3 | ch4) < 0)
+    		throw new EOFException();
+    	return ((ch4 << 24) + (ch3 << 16) + (ch2 << 8) + (ch1 << 0));
+    }
+
+    /**
+     * Reads an unsigned 32-bit integer from this stream. This method reads 4 
+     * bytes from the stream, starting at the current stream pointer. 
+     * If the bytes read, in order, are <code>b1</code>,
+     * <code>b2</code>, <code>b3</code>, and <code>b4</code>, where 
+     * <code>0&nbsp;&lt;=&nbsp;b1, b2, b3, b4&nbsp;&lt;=&nbsp;255</code>, 
+     * then the result is equal to:
+     * <blockquote><pre>
+     *     (b1 &lt;&lt; 24) | (b2 &lt;&lt; 16) + (b3 &lt;&lt; 8) + b4
+     * </pre></blockquote>
+     * <p>
+     * This method blocks until the four bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next four bytes of this stream, interpreted as a
+     *             <code>long</code>.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *               four bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final long readUnsignedInt() throws IOException {
+    	long ch1 = this.read();
+    	long ch2 = this.read();
+    	long ch3 = this.read();
+    	long ch4 = this.read();
+    	if ((ch1 | ch2 | ch3 | ch4) < 0)
+    		throw new EOFException();
+    	return ((ch1 << 24) + (ch2 << 16) + (ch3 << 8) + (ch4 << 0));
+    }
+
+    private byte[] ruileBuf = new byte[4];
+
+    /**
+     * Reads an unsigned 32-bit integer from this stream in little-endian
+     * order.  This method reads 4 
+     * bytes from the stream, starting at the current stream pointer. 
+     * If the bytes read, in order, are <code>b1</code>,
+     * <code>b2</code>, <code>b3</code>, and <code>b4</code>, where 
+     * <code>0&nbsp;&lt;=&nbsp;b1, b2, b3, b4&nbsp;&lt;=&nbsp;255</code>, 
+     * then the result is equal to:
+     * <blockquote><pre>
+     *     (b4 &lt;&lt; 24) | (b3 &lt;&lt; 16) + (b2 &lt;&lt; 8) + b1
+     * </pre></blockquote>
+     * <p>
+     * This method blocks until the four bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next four bytes of this stream, interpreted as a
+     *             <code>long</code>.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *               four bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final long readUnsignedIntLE() throws IOException {
+        this.readFully(ruileBuf);
+        long ch1 = (ruileBuf[0] & 0xff);
+        long ch2 = (ruileBuf[1] & 0xff);
+        long ch3 = (ruileBuf[2] & 0xff);
+        long ch4 = (ruileBuf[3] & 0xff);
+
+        return ((ch4 << 24) + (ch3 << 16) + (ch2 << 8) + (ch1 << 0));
+    }
+
+    /**
+     * Reads a signed 64-bit integer from this stream. This method reads eight
+     * bytes from the stream, starting at the current stream pointer. 
+     * If the bytes read, in order, are 
+     * <code>b1</code>, <code>b2</code>, <code>b3</code>, 
+     * <code>b4</code>, <code>b5</code>, <code>b6</code>, 
+     * <code>b7</code>, and <code>b8,</code> where:
+     * <blockquote><pre>
+     *     0 &lt;= b1, b2, b3, b4, b5, b6, b7, b8 &lt;=255,
+     * </pre></blockquote>
+     * <p>
+     * then the result is equal to:
+     * <p><blockquote><pre>
+     *     ((long)b1 &lt;&lt; 56) + ((long)b2 &lt;&lt; 48)
+     *     + ((long)b3 &lt;&lt; 40) + ((long)b4 &lt;&lt; 32)
+     *     + ((long)b5 &lt;&lt; 24) + ((long)b6 &lt;&lt; 16)
+     *     + ((long)b7 &lt;&lt; 8) + b8
+     * </pre></blockquote>
+     * <p>
+     * This method blocks until the eight bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next eight bytes of this stream, interpreted as a
+     *             <code>long</code>.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *               eight bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final long readLong() throws IOException {
+    	return ((long)(readInt()) << 32) + (readInt() & 0xFFFFFFFFL);
+    }
+
+    /**
+     * Reads a signed 64-bit integer from this stream in little-endian
+     * order. This method reads eight
+     * bytes from the stream, starting at the current stream pointer. 
+     * If the bytes read, in order, are 
+     * <code>b1</code>, <code>b2</code>, <code>b3</code>, 
+     * <code>b4</code>, <code>b5</code>, <code>b6</code>, 
+     * <code>b7</code>, and <code>b8,</code> where:
+     * <blockquote><pre>
+     *     0 &lt;= b1, b2, b3, b4, b5, b6, b7, b8 &lt;=255,
+     * </pre></blockquote>
+     * <p>
+     * then the result is equal to:
+     * <p><blockquote><pre>
+     *     ((long)b1 &lt;&lt; 56) + ((long)b2 &lt;&lt; 48)
+     *     + ((long)b3 &lt;&lt; 40) + ((long)b4 &lt;&lt; 32)
+     *     + ((long)b5 &lt;&lt; 24) + ((long)b6 &lt;&lt; 16)
+     *     + ((long)b7 &lt;&lt; 8) + b8
+     * </pre></blockquote>
+     * <p>
+     * This method blocks until the eight bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next eight bytes of this stream, interpreted as a
+     *             <code>long</code>.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *               eight bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final long readLongLE() throws IOException {
+        int i1 = readIntLE();
+        int i2 = readIntLE();
+        return ((long)i2 << 32) + (i1 & 0xFFFFFFFFL);
+    }
+
+    /**
+     * Reads a <code>float</code> from this stream. This method reads an 
+     * <code>int</code> value, starting at the current stream pointer, 
+     * as if by the <code>readInt</code> method 
+     * and then converts that <code>int</code> to a <code>float</code> 
+     * using the <code>intBitsToFloat</code> method in class 
+     * <code>Float</code>. 
+     * <p>
+     * This method blocks until the four bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next four bytes of this stream, interpreted as a
+     *             <code>float</code>.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *             four bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final float readFloat() throws IOException {
+    	return Float.intBitsToFloat(readInt());
+    }
+
+    /**
+     * Reads a <code>float</code> from this stream in little-endian order.
+     * This method reads an 
+     * <code>int</code> value, starting at the current stream pointer, 
+     * as if by the <code>readInt</code> method 
+     * and then converts that <code>int</code> to a <code>float</code> 
+     * using the <code>intBitsToFloat</code> method in class 
+     * <code>Float</code>. 
+     * <p>
+     * This method blocks until the four bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next four bytes of this stream, interpreted as a
+     *             <code>float</code>.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *             four bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final float readFloatLE() throws IOException {
+    	return Float.intBitsToFloat(readIntLE());
+    }
+
+    /**
+     * Reads a <code>double</code> from this stream. This method reads a 
+     * <code>long</code> value, starting at the current stream pointer, 
+     * as if by the <code>readLong</code> method 
+     * and then converts that <code>long</code> to a <code>double</code> 
+     * using the <code>longBitsToDouble</code> method in 
+     * class <code>Double</code>.
+     * <p>
+     * This method blocks until the eight bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next eight bytes of this stream, interpreted as a
+     *             <code>double</code>.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *             eight bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final double readDouble() throws IOException {
+    	return Double.longBitsToDouble(readLong());
+    }
+
+    /**
+     * Reads a <code>double</code> from this stream in little-endian order.
+     * This method reads a 
+     * <code>long</code> value, starting at the current stream pointer, 
+     * as if by the <code>readLong</code> method 
+     * and then converts that <code>long</code> to a <code>double</code> 
+     * using the <code>longBitsToDouble</code> method in 
+     * class <code>Double</code>.
+     * <p>
+     * This method blocks until the eight bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     the next eight bytes of this stream, interpreted as a
+     *             <code>double</code>.
+     * @exception  EOFException  if this stream reaches the end before reading
+     *             eight bytes.
+     * @exception  IOException   if an I/O error occurs.
+     */
+    public final double readDoubleLE() throws IOException {
+    	return Double.longBitsToDouble(readLongLE());
+    }
+
+    /**
+     * Reads the next line of text from this stream.  This method successively
+     * reads bytes from the stream, starting at the current stream pointer, 
+     * until it reaches a line terminator or the end
+     * of the stream.  Each byte is converted into a character by taking the
+     * byte's value for the lower eight bits of the character and setting the
+     * high eight bits of the character to zero.  This method does not,
+     * therefore, support the full Unicode character set.
+     *
+     * <p> A line of text is terminated by a carriage-return character
+     * (<code>'&#92;r'</code>), a newline character (<code>'&#92;n'</code>), a
+     * carriage-return character immediately followed by a newline character,
+     * or the end of the stream.  Line-terminating characters are discarded and
+     * are not included as part of the string returned.
+     *
+     * <p> This method blocks until a newline character is read, a carriage
+     * return and the byte following it are read (to see if it is a newline),
+     * the end of the stream is reached, or an exception is thrown.
+     *
+     * @return     the next line of text from this stream, or null if end
+     *             of stream is encountered before even one byte is read.
+     * @exception  IOException  if an I/O error occurs.
+     */
+    public final String readLine() throws IOException {
+    	StringBuffer input = new StringBuffer();
+    	int c = -1;
+    	boolean eol = false;
+
+    	while (!eol) {
+    		switch (c = read()) {
+    			case -1:
+    			case '\n':
+    				eol = true;
+    				break;
+    			case '\r':
+    				eol = true;
+    				long cur = getFilePointer();
+    				if ((read()) != '\n') {
+    					seek(cur);
+    				}
+    				break;
+    			default:
+    				input.append((char)c);
+    				break;
+    		}
+    	}
+
+    	if ((c == -1) && (input.length() == 0)) {
+    		return null;
+    	}
+    	return input.toString();
+    }
+
+    /**
+     * Reads in a string from this stream. The string has been encoded 
+     * using a modified UTF-8 format. 
+     * <p>
+     * The first two bytes are read, starting from the current stream
+     * pointer, as if by 
+     * <code>readUnsignedShort</code>. This value gives the number of 
+     * following bytes that are in the encoded string, not
+     * the length of the resulting string. The following bytes are then 
+     * interpreted as bytes encoding characters in the UTF-8 format 
+     * and are converted into characters. 
+     * <p>
+     * This method blocks until all the bytes are read, the end of the 
+     * stream is detected, or an exception is thrown. 
+     *
+     * @return     a Unicode string.
+     * @exception  EOFException            if this stream reaches the end before
+     *               reading all the bytes.
+     * @exception  IOException             if an I/O error occurs.
+     * @exception  UTFDataFormatException  if the bytes do not represent 
+     *               valid UTF-8 encoding of a Unicode string.
+     */
+    public final String readUTF() throws IOException {
+    	return DataInputStream.readUTF(this);
+    }
+
+    /**
+     * Releases any system resources associated with this stream
+     * by calling the <code>close()</code> method.
+     */
+    protected void finalize() throws Throwable {
+        super.finalize();
+        close();
+    }
+}
diff --git a/src/pixy/io/WriteStrategy.java b/src/pixy/io/WriteStrategy.java
new file mode 100644
index 0000000..b5673ec
--- /dev/null
+++ b/src/pixy/io/WriteStrategy.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.io;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/29/2013
+ */
+public interface WriteStrategy {
+	//
+	public void writeInt(byte[] buf, int start_idx, int value) throws IOException;
+	public void writeInt(OutputStream os, int value) throws IOException;
+	public void writeLong(byte[] buf, int start_idx, long value) throws IOException;
+	public void writeLong(OutputStream os, long value) throws IOException;
+	public void writeS15Fixed16Number(byte[] buf, int start_idx, float value) throws IOException;
+	public void writeS15Fixed16Number(OutputStream os, float value) throws IOException;
+	public void writeShort(byte[] buf, int start_idx, int value) throws IOException;
+	public void writeShort(OutputStream os, int value) throws IOException;
+	public void writeU16Fixed16Number(byte[] buf, int start_idx, float value) throws IOException;
+	public void writeU16Fixed16Number(OutputStream os, float value) throws IOException;
+	public void writeU8Fixed8Number(byte[] buf, int start_idx, float value) throws IOException;
+    public void writeU8Fixed8Number(OutputStream is, float value) throws IOException;
+}
diff --git a/src/pixy/io/WriteStrategyII.java b/src/pixy/io/WriteStrategyII.java
new file mode 100644
index 0000000..4470f6f
--- /dev/null
+++ b/src/pixy/io/WriteStrategyII.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.io;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/29/2013
+ */
+public class WriteStrategyII implements WriteStrategy {
+
+	private static final WriteStrategyII instance = new WriteStrategyII();	
+	 
+	public static WriteStrategyII getInstance() 
+	{
+		return instance;
+	}
+	 
+	private WriteStrategyII(){}
+	
+	public void writeInt(byte[] buf, int start_idx, int value)
+			throws IOException {
+		
+		byte[] tmp = {(byte)value, (byte)(value>>>8), (byte)(value>>>16), (byte)(value>>>24)};
+
+		System.arraycopy(tmp, 0, buf, start_idx, 4);
+	}
+
+	public void writeInt(OutputStream os, int value) throws IOException {
+		os.write(new byte[] {
+	        (byte)value,
+	        (byte)(value>>>8),
+	        (byte)(value>>>16),
+	        (byte)(value>>>24)});
+	}
+	
+	public void writeLong(byte[] buf, int start_idx, long value)
+			throws IOException {
+		
+		byte[] tmp = {(byte)value, (byte)(value>>>8), (byte)(value>>>16),
+		           (byte)(value>>>24), (byte)(value>>>32), (byte)(value>>>40),
+			       (byte)(value>>>48), (byte)(value>>>56)};
+		
+		System.arraycopy(tmp, 0, buf, start_idx, 8);
+	}
+
+	public void writeLong(OutputStream os, long value) throws IOException {
+		os.write(new byte[] {
+	        (byte)value, (byte)(value>>>8),
+	        (byte)(value>>>16), (byte)(value>>>24),
+	        (byte)(value>>>32), (byte)(value>>>40),
+		    (byte)(value>>>48), (byte)(value>>>56)});
+	}
+	
+	public void writeS15Fixed16Number(byte[] buf, int start_idx, float value)
+			throws IOException {
+		// Check range
+		if((value < -32768.0f)||(value >= (32767 + (65535/65536.0f)))||Float.isNaN(value)) {
+			throw new IllegalArgumentException(value + " is not a valid S15Fixed16Number");
+		}
+		
+		if(value == 0.0f) {
+			writeInt(buf, start_idx, 0);
+		}
+		else if(value > 0.0f) {
+			writeU16Fixed16Number(buf, start_idx, value);
+		}
+		else {
+			int s15 = (int)Math.floor(value);
+			int fixed16 = (int)((value - s15)*65536.0f);
+			buf[start_idx++] = (byte)s15;
+			buf[start_idx++] = (byte)(s15>>>8);
+			buf[start_idx++] = (byte)fixed16;
+			buf[start_idx] = (byte)(fixed16>>>8);
+		}			
+	}
+
+	public void writeS15Fixed16Number(OutputStream os, float value) throws IOException {
+		// Check range
+		if((value < -32768.0f)||(value >= (32767 + (65535/65536.0f)))||Float.isNaN(value)) {
+			throw new IllegalArgumentException(value + " is not a valid S15Fixed16Number");
+		}
+		
+		if(value == 0.0f) {
+			writeInt(os, 0);
+		}
+		else if(value > 0.0f) {
+			writeU16Fixed16Number(os, value);
+		}
+		else {
+			int s15 = (int)Math.floor(value);
+			int fixed16 = (int)((value - s15)*65536.0f);
+			
+			os.write(new byte[] {
+				  (byte)s15,
+				  (byte)(s15 >>> 8),
+				  (byte)fixed16,
+				  (byte)(fixed16>>>8)
+				  });
+		}
+	}
+	
+	public void writeShort(byte[] buf, int start_idx, int value)
+			throws IOException {
+		buf[start_idx] = (byte)value;
+		buf[start_idx + 1] = (byte)(value>>>8);
+	}
+
+	public void writeShort(OutputStream os, int value) throws IOException {
+		os.write(new byte[] {
+			  (byte)value,
+			  (byte)(value >>> 8)
+			  });
+	}
+
+	public void writeU16Fixed16Number(byte[] buf, int start_idx, float value)
+			throws IOException {
+		// Check range
+		if((value < 0.0f)||(value >= (65535 + (65535/65536.0f)))||Float.isNaN(value)) {
+			throw new IllegalArgumentException(value + " is not a valid U16Fixed16Number");
+		}
+		if(value == 0.0f) {
+			writeInt(buf, start_idx, 0);
+		}
+		else {
+			int s15 = (int)value;
+			int fixed16 = (int)((value - s15)*65536.0f);
+			buf[start_idx++] = (byte)s15;
+			buf[start_idx++] = (byte)(s15>>>8);
+			buf[start_idx++] = (byte)fixed16;
+			buf[start_idx] = (byte)(fixed16>>>8);
+		}
+	}
+
+	public void writeU16Fixed16Number(OutputStream os, float value) throws IOException {
+		// Check range
+		if((value < 0.0f)||(value >= (65535 + (65535/65536.0f)))||Float.isNaN(value)) {
+			throw new IllegalArgumentException(value + " is not a valid U16Fixed16Number");
+		}
+		
+		if(value == 0.0f) {
+			writeInt(os, 0);
+		}
+		else {
+			int s15 = (int)value;
+			int fixed16 = (int)((value - s15)*65536.0f);
+			
+			os.write(new byte[] {
+				  (byte)s15,
+				  (byte)(s15 >>> 8),
+				  (byte)fixed16,
+				  (byte)(fixed16>>>8)
+				  });
+		}
+	}
+
+	public void writeU8Fixed8Number(byte[] buf, int start_idx, float value)
+			throws IOException {
+		// Check range
+		if((value < 0.0f)||(value >= (255 + (255/256.0f)))||Float.isNaN(value)) {
+			throw new IllegalArgumentException(value + " is not a valid U8ixed8Number");
+		}
+		if(value == 0.0f) {
+			writeShort(buf, start_idx, 0);
+		}
+		else {
+			int u8 = (int)value;
+			int fixed8 = (int)((value - u8)*256.0f);
+			buf[start_idx++] = (byte)u8;
+			buf[start_idx] = (byte)fixed8;
+		}
+	}
+
+	public void writeU8Fixed8Number(OutputStream os, float value) throws IOException {
+		// Check range
+		if((value < 0.0f)||(value >= (255 + (255/256.0f)))||Float.isNaN(value)) {
+			throw new IllegalArgumentException(value + " is not a valid U8Fixed8Number");
+		}
+		
+		if(value == 0.0f) {
+			writeShort(os, 0);
+		}
+		else {
+			int u8 = (int)value;
+			int fixed8 = (int)((value - u8)*256.0f);
+			
+			os.write(new byte[] {
+				  (byte)u8,
+				  (byte)fixed8
+				  });
+		}
+	}
+}
diff --git a/src/pixy/io/WriteStrategyMM.java b/src/pixy/io/WriteStrategyMM.java
new file mode 100644
index 0000000..5a641a4
--- /dev/null
+++ b/src/pixy/io/WriteStrategyMM.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.io;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/29/2013
+ */
+public class WriteStrategyMM implements WriteStrategy {
+
+	private static final WriteStrategyMM instance = new WriteStrategyMM();	
+	 
+	public static WriteStrategyMM getInstance() 
+	{
+		return instance;
+	}
+	 
+	private WriteStrategyMM(){}
+	
+	public void writeInt(byte[] buf, int start_idx, int value)
+			throws IOException {
+		
+		byte[] tmp =  {(byte)(value >>> 24), (byte)(value >>> 16), (byte)(value >>> 8), (byte)value};
+		
+		System.arraycopy(tmp, 0, buf, start_idx, 4);
+	}
+
+	public void writeInt(OutputStream os, int value) throws IOException {
+		os.write(new byte[] {
+			 (byte)(value >>> 24),
+		     (byte)(value >>> 16),
+		     (byte)(value >>> 8),
+		     (byte)value});
+	}
+	
+	public void writeLong(byte[] buf, int start_idx, long value)
+			throws IOException {
+		
+		byte[] tmp = new byte[] { (byte)(value>>>56), (byte)(value>>>48),
+			     (byte)(value>>>40), (byte)(value>>>32), (byte)(value>>>24),
+			     (byte)(value>>>16), (byte)(value>>>8), (byte)value};
+		
+		System.arraycopy(tmp, 0, buf, start_idx, 8);
+	}
+
+	public void writeLong(OutputStream os, long value) throws IOException {
+		os.write(new byte[] {
+	         (byte)(value>>>56), (byte)(value>>>48),
+			 (byte)(value>>>40), (byte)(value>>>32),
+			 (byte)(value>>>24), (byte)(value>>>16),
+			 (byte)(value>>>8),  (byte)value});
+	}
+	
+	public void writeS15Fixed16Number(byte[] buf, int start_idx, float value)
+			throws IOException {
+		// Check range
+		if((value < -32768.0f)||(value >= (32767 + (65535/65536.0f)))||Float.isNaN(value)) {
+			throw new IllegalArgumentException(value + " is not a valid S15Fixed16Number");
+		}
+		
+		if(value == 0.0f) {
+			writeInt(buf, start_idx, 0);
+		}
+		else if(value > 0.0f) {
+			writeU16Fixed16Number(buf, start_idx, value);
+		}
+		else {
+			int s15 = (int)Math.floor(value);
+			int fixed16 = (int)((value - s15)*65536.0f);
+			buf[start_idx++] = (byte)(s15>>>8);
+			buf[start_idx++] = (byte)s15;
+			buf[start_idx++] = (byte)(fixed16>>>8);
+			buf[start_idx] = (byte)fixed16;
+		}			
+	}
+
+	public void writeS15Fixed16Number(OutputStream os, float value) throws IOException {
+		// Check range
+		if((value < -32768.0f)||(value >= (32767 + (65535/65536.0f)))||Float.isNaN(value)) {
+			throw new IllegalArgumentException(value + " is not a valid S15Fixed16Number");
+		}
+		
+		if(value == 0.0f) {
+			writeInt(os, 0);
+		}
+		else if(value > 0.0f) {
+			writeU16Fixed16Number(os, value);
+		}
+		else {
+			int s15 = (int)Math.floor(value);
+			int fixed16 = (int)((value - s15)*65536.0f);
+			
+			os.write(new byte[] {
+				  (byte)(s15 >>> 8),
+				  (byte)s15,
+				  (byte)(fixed16>>>8),
+				  (byte)fixed16
+				  });
+		}
+	}
+
+	public void writeShort(byte[] buf, int start_idx, int value)
+			throws IOException {
+		buf[start_idx] = (byte)(value>>>8);
+		buf[start_idx + 1] = (byte)value;
+	}
+
+	public void writeShort(OutputStream os, int value) throws IOException {
+		os.write(new byte[] {
+				 (byte)(value >>> 8),
+			     (byte)value});
+	}
+
+	public void writeU16Fixed16Number(byte[] buf, int start_idx, float value)
+			throws IOException {
+		// Check range
+		if((value < 0.0f)||(value >= (65535 + (65535/65536.0f)))||Float.isNaN(value)) {
+			throw new IllegalArgumentException(value + " is not a valid U16Fixed16Number");
+		}
+		if(value == 0.0f) {
+			writeInt(buf, start_idx, 0);
+		}
+		else {
+			int s15 = (int)value;
+			int fixed16 = (int)((value - s15)*65536.0f);
+			buf[start_idx++] = (byte)(s15>>>8);
+			buf[start_idx++] = (byte)s15;
+			buf[start_idx++] = (byte)(fixed16>>>8);
+			buf[start_idx] = (byte)fixed16;			
+		}
+	}
+
+	public void writeU16Fixed16Number(OutputStream os, float value) throws IOException {
+		// Check range
+		if((value < 0.0f)||(value >= (65535 + (65535/65536.0f)))||Float.isNaN(value)) {
+			throw new IllegalArgumentException(value + " is not a valid U16Fixed16Number");
+		}
+		
+		if(value == 0.0f) {
+			writeInt(os, 0);
+		}
+		else {
+			int s15 = (int)value;
+			int fixed16 = (int)((value - s15)*65536.0f);
+			
+			os.write(new byte[] {
+				  (byte)(s15 >>> 8),
+				  (byte)s15,
+				  (byte)(fixed16>>>8),
+				  (byte)fixed16				  
+				  });
+		}
+	}
+
+	public void writeU8Fixed8Number(byte[] buf, int start_idx, float value)
+			throws IOException {
+		// Check range
+		if((value < 0.0f)||(value >= (255 + (255/256.0f)))||Float.isNaN(value)) {
+			throw new IllegalArgumentException(value + " is not a valid U8ixed8Number");
+		}
+		if(value == 0.0f) {
+			writeShort(buf, start_idx, 0);
+		}
+		else {
+			int u8 = (int)value;
+			int fixed8 = (int)((value - u8)*256.0f);
+			buf[start_idx++] = (byte)u8;
+			buf[start_idx] = (byte)fixed8;
+		}
+	}
+
+	public void writeU8Fixed8Number(OutputStream os, float value) throws IOException {
+		// Check range
+		if((value < 0.0f)||(value >= (255 + (255/256.0f)))||Float.isNaN(value)) {
+			throw new IllegalArgumentException(value + " is not a valid U8Fixed8Number");
+		}
+		
+		if(value == 0.0f) {
+			writeShort(os, 0);
+		}
+		else {
+			int u8 = (int)value;
+			int fixed8 = (int)((value - u8)*256.0f);
+			
+			os.write(new byte[] {
+				  (byte)u8,
+				  (byte)fixed8
+				  });
+		}
+	}
+}
diff --git a/src/pixy/meta/Metadata.java b/src/pixy/meta/Metadata.java
new file mode 100644
index 0000000..a731d24
--- /dev/null
+++ b/src/pixy/meta/Metadata.java
@@ -0,0 +1,544 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * Metadata.java
+ *
+ * Who   Date       Description
+ * ====  =========  ======================================================
+ * WY    26Sep2015  Added insertComment(InputStream, OutputStream, String}
+ * WY    06Jul2015  Added insertXMP(InputSream, OutputStream, XMP)
+ * WY    16Apr2015  Changed insertIRB() parameter List to Collection
+ * WY    16Apr2015  Removed ICC_Profile related code
+ * WY    13Mar2015  initial creation
+ */
+
+package pixy.meta;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import android.graphics.Bitmap;
+import pixy.meta.Metadata;
+import pixy.util.MetadataUtils;
+import pixy.meta.MetadataReader;
+import pixy.meta.MetadataType;
+import pixy.meta.adobe._8BIM;
+import pixy.meta.bmp.BMPMeta;
+import pixy.meta.exif.Exif;
+import pixy.meta.gif.GIFMeta;
+import pixy.meta.iptc.IPTCDataSet;
+import pixy.meta.jpeg.JPGMeta;
+import pixy.meta.png.PNGMeta;
+import pixy.meta.tiff.TIFFMeta;
+import pixy.meta.xmp.XMP;
+import pixy.image.ImageType;
+import pixy.io.FileCacheRandomAccessInputStream;
+import pixy.io.FileCacheRandomAccessOutputStream;
+import pixy.io.PeekHeadInputStream;
+import pixy.io.RandomAccessInputStream;
+import pixy.io.RandomAccessOutputStream;
+
+/**
+ * Base class for image metadata.
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/12/2015
+ */
+public abstract class Metadata implements MetadataReader, Iterable<MetadataEntry> {
+	public static final int IMAGE_MAGIC_NUMBER_LEN = 4;
+	// Fields
+	private MetadataType type;
+	protected byte[] data;
+	protected boolean isDataRead;
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(Metadata.class);		
+	
+	public static void  extractThumbnails(File image, String pathToThumbnail) throws IOException {
+		FileInputStream fin = new FileInputStream(image);
+		extractThumbnails(fin, pathToThumbnail);
+		fin.close();
+	}
+	
+	public static void extractThumbnails(InputStream is, String pathToThumbnail) throws IOException {
+		// ImageIO.IMAGE_MAGIC_NUMBER_LEN bytes as image magic number
+		PeekHeadInputStream peekHeadInputStream = new PeekHeadInputStream(is, IMAGE_MAGIC_NUMBER_LEN);
+		ImageType imageType = MetadataUtils.guessImageType(peekHeadInputStream);		
+		// Delegate thumbnail extracting to corresponding image tweaker.
+		switch(imageType) {
+			case JPG:
+				JPGMeta.extractThumbnails(peekHeadInputStream, pathToThumbnail);
+				break;
+			case TIFF:
+				RandomAccessInputStream randIS = new FileCacheRandomAccessInputStream(peekHeadInputStream);
+				TIFFMeta.extractThumbnail(randIS, pathToThumbnail);
+				randIS.shallowClose();
+				break;
+			case PNG:
+				LOGGER.info("PNG image format does not contain any thumbnail");
+				break;
+			case GIF:
+			case PCX:
+			case TGA:
+			case BMP:
+				LOGGER.info("{} image format does not contain any thumbnails", imageType);
+				break;
+			default:
+				peekHeadInputStream.close();
+				throw new IllegalArgumentException("Thumbnail extracting is not supported for " + imageType + " image");				
+		}
+		peekHeadInputStream.shallowClose();
+	}
+	
+	public static void extractThumbnails(String image, String pathToThumbnail) throws IOException {
+		extractThumbnails(new File(image), pathToThumbnail);
+	}
+	
+	public static void insertComment(InputStream is, OutputStream os, String comment) throws IOException {
+		insertComments(is, os, Arrays.asList(comment));
+	}
+	
+	public static void insertComments(InputStream is, OutputStream os, List<String> comments) throws IOException {
+		// ImageIO.IMAGE_MAGIC_NUMBER_LEN bytes as image magic number
+		PeekHeadInputStream peekHeadInputStream = new PeekHeadInputStream(is, IMAGE_MAGIC_NUMBER_LEN);
+		ImageType imageType = MetadataUtils.guessImageType(peekHeadInputStream);		
+		// Delegate IPTC inserting to corresponding image tweaker.
+		switch(imageType) {
+			case JPG:
+				JPGMeta.insertComments(peekHeadInputStream, os, comments);
+				break;
+			case TIFF:
+				RandomAccessInputStream randIS = new FileCacheRandomAccessInputStream(peekHeadInputStream);
+				RandomAccessOutputStream randOS = new FileCacheRandomAccessOutputStream(os);
+				TIFFMeta.insertComments(comments, randIS, randOS);
+				randIS.shallowClose();
+				randOS.shallowClose();
+				break;
+			case PNG:
+				PNGMeta.insertComments(peekHeadInputStream, os, comments);
+				break;
+			case GIF:
+				GIFMeta.insertComments(peekHeadInputStream, os, comments);
+				break;
+			case PCX:
+			case TGA:
+			case BMP:
+				LOGGER.info("{} image format does not support comment data", imageType);
+				break;
+			default:
+				peekHeadInputStream.close();
+				throw new IllegalArgumentException("comment data inserting is not supported for " + imageType + " image");				
+		}
+		peekHeadInputStream.shallowClose();
+	}
+	
+	/**
+	 * @param is input image stream 
+	 * @param os output image stream
+	 * @param exif Exif instance
+	 * @throws IOException 
+	 */
+	public static void insertExif(InputStream is, OutputStream os, Exif exif) throws IOException {
+		insertExif(is, os, exif, false);
+	}
+	
+	/**
+	 * @param is input image stream 
+	 * @param os output image stream
+	 * @param exif Exif instance
+	 * @param update true to keep the original data, otherwise false
+	 * @throws IOException 
+	 */
+	public static void insertExif(InputStream is, OutputStream os, Exif exif, boolean update) throws IOException {
+		// ImageIO.IMAGE_MAGIC_NUMBER_LEN bytes as image magic number
+		PeekHeadInputStream peekHeadInputStream = new PeekHeadInputStream(is, IMAGE_MAGIC_NUMBER_LEN);
+		ImageType imageType = MetadataUtils.guessImageType(peekHeadInputStream);		
+		// Delegate EXIF inserting to corresponding image tweaker.
+		switch(imageType) {
+			case JPG:
+				JPGMeta.insertExif(peekHeadInputStream, os, exif, update);
+				break;
+			case TIFF:
+				RandomAccessInputStream randIS = new FileCacheRandomAccessInputStream(peekHeadInputStream);
+				RandomAccessOutputStream randOS = new FileCacheRandomAccessOutputStream(os);
+				TIFFMeta.insertExif(randIS, randOS, exif, update);
+				randIS.shallowClose();
+				randOS.shallowClose();
+				break;
+			case GIF:
+			case PCX:
+			case TGA:
+			case BMP:
+			case PNG:
+				LOGGER.info("{} image format does not support EXIF data", imageType);
+				break;
+			default:
+				peekHeadInputStream.close();
+				throw new IllegalArgumentException("EXIF data inserting is not supported for " + imageType + " image");				
+		}
+		peekHeadInputStream.shallowClose();
+	}
+	
+	public static void insertICCProfile(InputStream is, OutputStream out, byte[] icc_profile) throws IOException {
+		// ImageIO.IMAGE_MAGIC_NUMBER_LEN bytes as image magic number
+		PeekHeadInputStream peekHeadInputStream = new PeekHeadInputStream(is, IMAGE_MAGIC_NUMBER_LEN);
+		ImageType imageType = MetadataUtils.guessImageType(peekHeadInputStream);		
+		// Delegate ICCP inserting to corresponding image tweaker.
+		switch(imageType) {
+			case JPG:
+				JPGMeta.insertICCProfile(peekHeadInputStream, out, icc_profile);
+				break;
+			case TIFF:
+				RandomAccessInputStream randIS = new FileCacheRandomAccessInputStream(peekHeadInputStream);
+				RandomAccessOutputStream randOS = new FileCacheRandomAccessOutputStream(out);
+				TIFFMeta.insertICCProfile(icc_profile, 0, randIS, randOS);
+				randIS.shallowClose();
+				randOS.shallowClose();
+				break;
+			case GIF:
+			case PCX:
+			case TGA:
+			case BMP:
+				LOGGER.info("{} image format does not support ICCProfile data", imageType);
+				break;
+			default:
+				peekHeadInputStream.close();
+				throw new IllegalArgumentException("ICCProfile data inserting is not supported for " + imageType + " image");				
+		}
+		peekHeadInputStream.shallowClose();
+	}
+
+	public static void insertIPTC(InputStream is, OutputStream out, Collection<IPTCDataSet> iptcs) throws IOException {
+		insertIPTC(is, out, iptcs, false);
+	}
+	
+	public static void insertIPTC(InputStream is, OutputStream out, Collection<IPTCDataSet> iptcs, boolean update) throws IOException {
+		// ImageIO.IMAGE_MAGIC_NUMBER_LEN bytes as image magic number
+		PeekHeadInputStream peekHeadInputStream = new PeekHeadInputStream(is, IMAGE_MAGIC_NUMBER_LEN);
+		ImageType imageType = MetadataUtils.guessImageType(peekHeadInputStream);		
+		// Delegate IPTC inserting to corresponding image tweaker.
+		switch(imageType) {
+			case JPG:
+				JPGMeta.insertIPTC(peekHeadInputStream, out, iptcs, update);
+				break;
+			case TIFF:
+				RandomAccessInputStream randIS = new FileCacheRandomAccessInputStream(peekHeadInputStream);
+				RandomAccessOutputStream randOS = new FileCacheRandomAccessOutputStream(out);
+				TIFFMeta.insertIPTC(randIS, randOS, iptcs, update);
+				randIS.shallowClose();
+				randOS.shallowClose();
+				break;
+			case PNG:
+			case GIF:
+			case PCX:
+			case TGA:
+			case BMP:
+				LOGGER.info("{} image format does not support IPTC data", imageType);
+				break;
+			default:
+				peekHeadInputStream.close();
+				throw new IllegalArgumentException("IPTC data inserting is not supported for " + imageType + " image");				
+		}
+		peekHeadInputStream.shallowClose();
+	}
+	
+	public static void insertIRB(InputStream is, OutputStream out, Collection<_8BIM> bims) throws IOException {
+		insertIRB(is, out, bims, false);
+	}
+	
+	public static void insertIRB(InputStream is, OutputStream os, Collection<_8BIM> bims, boolean update) throws IOException {
+		// ImageIO.IMAGE_MAGIC_NUMBER_LEN bytes as image magic number
+		PeekHeadInputStream peekHeadInputStream = new PeekHeadInputStream(is, IMAGE_MAGIC_NUMBER_LEN);
+		ImageType imageType = MetadataUtils.guessImageType(peekHeadInputStream);		
+		// Delegate IRB inserting to corresponding image tweaker.
+		switch(imageType) {
+			case JPG:
+				JPGMeta.insertIRB(peekHeadInputStream, os, bims, update);
+				break;
+			case TIFF:
+				RandomAccessInputStream randIS = new FileCacheRandomAccessInputStream(peekHeadInputStream);
+				RandomAccessOutputStream randOS = new FileCacheRandomAccessOutputStream(os);
+				TIFFMeta.insertIRB(randIS, randOS, bims, update);
+				randIS.shallowClose();
+				randOS.shallowClose();
+				break;
+			case PNG:
+			case GIF:
+			case PCX:
+			case TGA:
+			case BMP:
+				LOGGER.info("{} image format does not support IRB data", imageType);
+				break;
+			default:
+				peekHeadInputStream.close();
+				throw new IllegalArgumentException("IRB data inserting is not supported for " + imageType + " image");				
+		}
+		peekHeadInputStream.shallowClose();
+	}
+	
+	public static void insertIRBThumbnail(InputStream is, OutputStream out, Bitmap thumbnail) throws IOException {
+		// ImageIO.IMAGE_MAGIC_NUMBER_LEN bytes as image magic number
+		PeekHeadInputStream peekHeadInputStream = new PeekHeadInputStream(is, IMAGE_MAGIC_NUMBER_LEN);
+		ImageType imageType = MetadataUtils.guessImageType(peekHeadInputStream);		
+		// Delegate IRB thumbnail inserting to corresponding image tweaker.
+		switch(imageType) {
+			case JPG:
+				JPGMeta.insertIRBThumbnail(peekHeadInputStream, out, thumbnail);
+				break;
+			case TIFF:
+				RandomAccessInputStream randIS = new FileCacheRandomAccessInputStream(peekHeadInputStream);
+				RandomAccessOutputStream randOS = new FileCacheRandomAccessOutputStream(out);
+				TIFFMeta.insertThumbnail(randIS, randOS, thumbnail);
+				randIS.shallowClose();
+				randOS.shallowClose();
+				break;
+			case PNG:
+			case GIF:
+			case PCX:
+			case TGA:
+			case BMP:
+				LOGGER.info("{} image format does not support IRB thumbnail", imageType);
+				break;
+			default:
+				peekHeadInputStream.close();
+				throw new IllegalArgumentException("IRB thumbnail inserting is not supported for " + imageType + " image");				
+		}
+		peekHeadInputStream.shallowClose();
+	}
+	
+	public static void insertXMP(InputStream is, OutputStream out, XMP xmp) throws IOException {
+		// ImageIO.IMAGE_MAGIC_NUMBER_LEN bytes as image magic number
+		PeekHeadInputStream peekHeadInputStream = new PeekHeadInputStream(is, IMAGE_MAGIC_NUMBER_LEN);
+		ImageType imageType = MetadataUtils.guessImageType(peekHeadInputStream);		
+		// Delegate XMP inserting to corresponding image tweaker.
+		switch(imageType) {
+			case JPG:
+				JPGMeta.insertXMP(peekHeadInputStream, out, xmp); // No ExtendedXMP
+				break;
+			case TIFF:
+				RandomAccessInputStream randIS = new FileCacheRandomAccessInputStream(peekHeadInputStream);
+				RandomAccessOutputStream randOS = new FileCacheRandomAccessOutputStream(out);
+				TIFFMeta.insertXMP(xmp, randIS, randOS);
+				randIS.shallowClose();
+				randOS.shallowClose();
+				break;
+			case PNG:
+				PNGMeta.insertXMP(peekHeadInputStream, out, xmp);
+				break;
+			case GIF:
+				GIFMeta.insertXMPApplicationBlock(peekHeadInputStream, out, xmp);
+				break;
+			case PCX:
+			case TGA:
+			case BMP:
+				LOGGER.info("{} image format does not support XMP data", imageType);
+				break;
+			default:
+				peekHeadInputStream.close();
+				throw new IllegalArgumentException("XMP inserting is not supported for " + imageType + " image");				
+		}
+		peekHeadInputStream.shallowClose();
+	}
+	
+	public static void insertXMP(InputStream is, OutputStream out, String xmp) throws IOException {
+		// ImageIO.IMAGE_MAGIC_NUMBER_LEN bytes as image magic number
+		PeekHeadInputStream peekHeadInputStream = new PeekHeadInputStream(is, IMAGE_MAGIC_NUMBER_LEN);
+		ImageType imageType = MetadataUtils.guessImageType(peekHeadInputStream);		
+		// Delegate XMP inserting to corresponding image tweaker.
+		switch(imageType) {
+			case JPG:
+				JPGMeta.insertXMP(peekHeadInputStream, out, xmp, null); // No ExtendedXMP
+				break;
+			case TIFF:
+				RandomAccessInputStream randIS = new FileCacheRandomAccessInputStream(peekHeadInputStream);
+				RandomAccessOutputStream randOS = new FileCacheRandomAccessOutputStream(out);
+				TIFFMeta.insertXMP(xmp, randIS, randOS);
+				randIS.shallowClose();
+				randOS.shallowClose();
+				break;
+			case PNG:
+				PNGMeta.insertXMP(peekHeadInputStream, out, xmp);
+				break;
+			case GIF:
+				GIFMeta.insertXMPApplicationBlock(peekHeadInputStream, out, xmp);
+				break;
+			case PCX:
+			case TGA:
+			case BMP:
+				LOGGER.info("{} image format does not support XMP data", imageType);
+				break;
+			default:
+				peekHeadInputStream.close();
+				throw new IllegalArgumentException("XMP inserting is not supported for " + imageType + " image");				
+		}
+		peekHeadInputStream.shallowClose();
+	}
+	
+	public static Map<MetadataType, Metadata> readMetadata(File image) throws IOException {
+		FileInputStream fin = new FileInputStream(image);
+		Map<MetadataType, Metadata> metadataMap = readMetadata(fin);
+		fin.close();
+		
+		return metadataMap; 
+	}
+	
+	/**
+	 * Reads all metadata associated with the input image
+	 *
+	 * @param is InputStream for the image
+	 * @return a list of Metadata for the input stream
+	 * @throws IOException
+	 */
+	public static Map<MetadataType, Metadata> readMetadata(InputStream is) throws IOException {
+		// Metadata map for all the Metadata read
+		Map<MetadataType, Metadata> metadataMap = new HashMap<MetadataType, Metadata>();
+		// ImageIO.IMAGE_MAGIC_NUMBER_LEN bytes as image magic number
+		PeekHeadInputStream peekHeadInputStream = new PeekHeadInputStream(is, IMAGE_MAGIC_NUMBER_LEN);
+		ImageType imageType = MetadataUtils.guessImageType(peekHeadInputStream);		
+		// Delegate metadata reading to corresponding image tweakers.
+		switch(imageType) {
+			case JPG:
+				metadataMap = JPGMeta.readMetadata(peekHeadInputStream);
+				break;
+			case TIFF:
+				RandomAccessInputStream randIS = new FileCacheRandomAccessInputStream(peekHeadInputStream);
+				metadataMap = TIFFMeta.readMetadata(randIS);
+				randIS.shallowClose();
+				break;
+			case PNG:
+				metadataMap = PNGMeta.readMetadata(peekHeadInputStream);
+				break;
+			case GIF:
+				metadataMap = GIFMeta.readMetadata(peekHeadInputStream);
+				break;
+			case BMP:
+				metadataMap = BMPMeta.readMetadata(peekHeadInputStream);
+				break;
+			default:
+				peekHeadInputStream.close();
+				throw new IllegalArgumentException("Metadata reading is not supported for " + imageType + " image");
+				
+		}	
+		peekHeadInputStream.shallowClose();
+		
+		return metadataMap;
+	}
+	
+	public static Map<MetadataType, Metadata> readMetadata(String image) throws IOException {
+		return readMetadata(new File(image));
+	}
+	
+	/**
+	 * Remove meta data from image
+	 * 
+	 * @param is InputStream for the input image
+	 * @param os OutputStream for the output image
+	 * @throws IOException
+	 */
+	public static Map<MetadataType, Metadata> removeMetadata(InputStream is, OutputStream os, MetadataType ...metadataTypes) throws IOException {
+		// ImageIO.IMAGE_MAGIC_NUMBER_LEN bytes as image magic number
+		PeekHeadInputStream peakHeadInputStream = new PeekHeadInputStream(is, IMAGE_MAGIC_NUMBER_LEN);
+		ImageType imageType = MetadataUtils.guessImageType(peakHeadInputStream);
+		// The map of removed metadata
+		Map<MetadataType, Metadata> metadataMap = Collections.emptyMap();
+		// Delegate meta data removing to corresponding image tweaker.
+		switch(imageType) {
+			case JPG:
+				metadataMap = JPGMeta.removeMetadata(peakHeadInputStream, os, metadataTypes);
+				break;
+			case TIFF:
+				RandomAccessInputStream randIS = new FileCacheRandomAccessInputStream(peakHeadInputStream);
+				RandomAccessOutputStream randOS = new FileCacheRandomAccessOutputStream(os);
+				metadataMap = TIFFMeta.removeMetadata(randIS, randOS, metadataTypes);
+				randIS.shallowClose();
+				randOS.shallowClose();
+				break;
+			case PCX:
+			case TGA:
+			case BMP:
+				LOGGER.info("{} image format does not support meta data", imageType);
+				break;
+			default:
+				peakHeadInputStream.close();
+				throw new IllegalArgumentException("Metadata removing is not supported for " + imageType + " image");				
+		}
+		
+		peakHeadInputStream.shallowClose();
+		
+		return metadataMap;
+	}
+	
+	public Metadata(MetadataType type) {
+		this.type = type;
+	}
+	
+	public Metadata(MetadataType type, byte[] data) {
+		if(type == null) throw new IllegalArgumentException("Metadata type must be specified");
+		if(data == null) throw new IllegalArgumentException("Input data array is null");
+		if(data.length == 0) isDataRead = true; // Allow for zero length data but disable read
+		this.type = type;
+		this.data = data;		
+	}
+	
+	public void ensureDataRead() {
+		if(!isDataRead) {
+			try {
+				read();
+			} catch (IOException e) {
+				e.printStackTrace();
+			}			
+		}
+	}
+	
+	public byte[] getData() {
+		if(data != null)
+			return data.clone();
+		
+		return null;
+	}
+	
+	public MetadataType getType() {
+		return type;
+	}
+	
+	public boolean isDataRead() {
+		return isDataRead;
+	}
+	
+	/**
+	 * Writes the metadata out to the output stream
+	 * 
+	 * @param out OutputStream to write the metadata to
+	 * @throws IOException
+	 */
+	public void write(OutputStream out) throws IOException {
+		byte[] data = getData();
+		if(data != null)
+			out.write(data);
+	}	
+}
diff --git a/src/pixy/meta/MetadataEntry.java b/src/pixy/meta/MetadataEntry.java
new file mode 100644
index 0000000..2aa7546
--- /dev/null
+++ b/src/pixy/meta/MetadataEntry.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * MetadataEntry.java
+ */
+
+package pixy.meta;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+
+public class MetadataEntry {
+	
+	private String key;
+	private String value;
+	private boolean isMetadataEntryGroup;
+	
+	private Collection<MetadataEntry> entries = new ArrayList<MetadataEntry>();
+
+	public MetadataEntry(String key, String value) {
+		this(key, value, false);
+	}
+	
+	public MetadataEntry(String key, String value, boolean isMetadataEntryGroup) {
+		this.key = key;
+		this.value = value;
+		this.isMetadataEntryGroup = isMetadataEntryGroup;
+	}
+	
+	public void addEntry(MetadataEntry entry) {
+		entries.add(entry);
+	}
+	
+	public void addEntries(Collection<MetadataEntry> newEntries) {
+		entries.addAll(newEntries);
+	}
+	
+	public String getKey() {
+		return key;
+	}
+	
+	public boolean isMetadataEntryGroup()  {
+		return isMetadataEntryGroup;
+	}
+	
+	public Collection<MetadataEntry> getMetadataEntries() {
+		return Collections.unmodifiableCollection(entries);
+	}
+	
+	public String getValue() {
+		return value;
+	}
+}
diff --git a/src/pixy/meta/MetadataReader.java b/src/pixy/meta/MetadataReader.java
new file mode 100644
index 0000000..754e8e5
--- /dev/null
+++ b/src/pixy/meta/MetadataReader.java
@@ -0,0 +1,9 @@
+package pixy.meta;
+
+import pixy.util.Reader;
+
+public interface MetadataReader extends Reader {
+	public MetadataType getType();
+	public void ensureDataRead();
+	public boolean isDataRead();
+}
diff --git a/src/pixy/meta/MetadataType.java b/src/pixy/meta/MetadataType.java
new file mode 100644
index 0000000..b623388
--- /dev/null
+++ b/src/pixy/meta/MetadataType.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta;
+
+public enum MetadataType {
+	EXIF, // EXIF
+	IPTC, // IPTC
+	ICC_PROFILE, // ICC Profile
+	XMP, // Adobe XMP
+	PHOTOSHOP_IRB, // PHOTOSHOP Image Resource Block
+	PHOTOSHOP_DDB, // PHOTOSHOP Document Data Block
+	COMMENT, // General comment
+	IMAGE, // Image specific information
+	JPG_JFIF, // JPEG APP0 (JFIF)
+	JPG_DUCKY, // JPEG APP12 (DUCKY)
+	JPG_ADOBE, // JPEG APP14 (ADOBE)
+	PNG_TEXTUAL, // PNG textual information
+	PNG_TIME; // PNG tIME (last modified time) chunk
+}
diff --git a/src/pixy/meta/Thumbnail.java b/src/pixy/meta/Thumbnail.java
new file mode 100644
index 0000000..3306b06
--- /dev/null
+++ b/src/pixy/meta/Thumbnail.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * ExifThumbnail.java
+ *
+ * Who   Date         Description
+ * ====  ==========   ==================================================
+ * WY    27Apr2015    Make fields protected for subclass copy constructor
+ * WY    10Apr2015    Changed to abstract class, added write()
+ * WY    09Apr2015    Added setWriteQuality()
+ */ 
+
+package pixy.meta;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import android.graphics.*;
+
+public abstract class Thumbnail {
+	// Internal data type for thumbnail represented by a Bitmap
+	public static final int DATA_TYPE_KRawRGB = 0; // For ExifThumbnail and IRBThumbnail
+	// Represented by a byte array of JPEG
+	public static final int DATA_TYPE_KJpegRGB = 1; // For ExifThumbnail and IRBThumbnail
+	// Represented by a byte array of uncompressed TIFF
+	public static final int DATA_TYPE_TIFF = 2; // For ExifThumbnail only
+	
+	protected Bitmap thumbnail;
+	protected byte[] compressedThumbnail;
+	
+	protected int writeQuality = 100; // Default JPEG write quality
+	
+	protected int width;
+	protected int height;
+	
+	// Default data type
+	protected int dataType = Thumbnail.DATA_TYPE_KRawRGB;
+	
+	public Thumbnail() {}
+	
+	public Thumbnail(Bitmap thumbnail) {
+		setImage(thumbnail);
+	}
+	
+	public Thumbnail(int width, int height, int dataType, byte[] compressedThumbnail) {
+		setImage(width, height, dataType, compressedThumbnail);
+	}
+	
+	public boolean containsImage() {
+		return thumbnail != null || compressedThumbnail != null;
+	}
+	
+	public byte[] getCompressedImage() {
+		return compressedThumbnail;
+	}
+	
+	public int getDataType() {
+		return dataType;
+	}
+	
+	public String getDataTypeAsString() {
+		switch(dataType) {
+			case 0:
+				return "DATA_TYPE_KRawRGB";
+			case 1:
+				return "DATA_TYPE_KJpegRGB";
+			case 2:
+				return "DATA_TYPE_TIFF";
+			default:
+				return "DATA_TYPE_Unknown";
+		}
+	}
+	
+	public int getHeight() {
+		return height;
+	}
+	
+	public Bitmap getRawImage() {
+		return thumbnail;
+	}
+	
+	public int getWidth() {
+		return width;
+	}
+	
+	public void setImage(Bitmap thumbnail) {
+		this.width = thumbnail.getWidth();
+		this.height = thumbnail.getHeight();
+		this.thumbnail = thumbnail;
+		this.dataType = DATA_TYPE_KRawRGB;
+	}
+	
+	public void setImage(int width, int height, int dataType, byte[] compressedThumbnail) {
+		this.width = width;
+		this.height = height;
+		
+		if(dataType == DATA_TYPE_KJpegRGB || dataType == DATA_TYPE_TIFF) {
+			this.compressedThumbnail = compressedThumbnail;
+			this.dataType = dataType;
+		}
+	}
+	
+	public void setWriteQuality(int quality) {
+		this.writeQuality = quality;
+	}
+	
+	public abstract void write(OutputStream os) throws IOException;
+}
diff --git a/src/pixy/meta/adobe/BlendModeKey.java b/src/pixy/meta/adobe/BlendModeKey.java
new file mode 100644
index 0000000..489d86d
--- /dev/null
+++ b/src/pixy/meta/adobe/BlendModeKey.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * BlendModeKey.java - Adobe Photoshop layer blend mode keys
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    28Jul2015  Initial creation
+ */
+
+package pixy.meta.adobe;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public enum BlendModeKey {
+	pass("pass through", 0x70617373),
+	norm("normal", 0x6e6f726d),
+	diss("dissolve", 0x64697373),
+	dark("darken", 0x6461726b),
+	mul("multiply", 0x6d756c20),
+	idiv("color burn", 0x69646976),
+	lbrn("linear burn", 0x6c62726e),
+	dkCl("darker color", 0x646b436c),
+	lite("lighten", 0x6c697465),
+	scrn("screen", 0x7363726e),
+	div("color dodge", 0x64697620),
+	lddg("linear dodge", 0x6c646467),
+	lgCl("lighter color", 0x6c67436c),
+	over("overlay", 0x6f766572),
+	sLit("soft light", 0x734c6974),
+	hLit("hard light", 0x684c6974),
+	vLit("vivid light", 0x764c6974),
+	lLit("linear light", 0x6c4c6974),
+	pLit("pin light", 0x704c6974),
+	hMix("hard mix", 0x684d6978),
+	diff("difference", 0x64696666),
+	smud("exclusion", 0x736d7564),
+	fsub("subtract", 0x66737562),
+	fdiv("divide", 0x66646976),
+	hue("hue", 0x68756520),
+	sat("saturation", 0x73617420),
+	colr("color", 0x636f6c72),
+	lum("luminosity", 0x6c756d20),
+	
+	UNKNOWN("Unknown Blending Mode", 0xFFFFFFFF);
+	
+	private BlendModeKey(String description, int value) {
+		this.description = description;
+		this.value = value;
+	}
+	
+	public String getDescription() {
+		return description;
+	}
+	
+	public int getValue() {
+		return value;
+	}
+	
+	public static BlendModeKey fromInt(int value) {
+       	BlendModeKey key = keyMap.get(value);
+    	if (key == null)
+    		return UNKNOWN;
+   		return key;
+    }
+	
+	private static final Map<Integer, BlendModeKey> keyMap = new HashMap<Integer, BlendModeKey>();
+    
+	static
+    {
+      for(BlendModeKey key : values()) {
+           keyMap.put(key.getValue(), key);
+      }
+    }
+	
+ 	private final String description;
+	private final int value;
+}
diff --git a/src/pixy/meta/adobe/Channel.java b/src/pixy/meta/adobe/Channel.java
new file mode 100644
index 0000000..57a3090
--- /dev/null
+++ b/src/pixy/meta/adobe/Channel.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * Channel.java
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================================
+ * WY    27Jul2015  initial creation
+ */
+
+package pixy.meta.adobe;
+
+public class Channel {
+	private int id;
+	private int dataLen;
+	
+	private static final int RED = 0;
+	private static final int GREEN = 1;
+	private static final int BLUE = 2;
+	
+	private static final int TRANSPARENCY_MASK = -1;
+	private static final int USER_SUPPLIED_LAYER_MASK = -2;
+	private static final int REAL_USER_SUPPLIED_LAYER_MASK = -3;
+		
+	public Channel(int id, int len) {
+		this.id = id;
+		this.dataLen = len;
+	}
+	
+	public int getDataLen() {
+		return dataLen;
+	}
+	
+	public int getID() {
+		return id;
+	}
+	
+	public String getType() {
+		switch(id) {
+			case RED:
+				return "Red channel";
+			case GREEN:
+				return "Green channel";
+			case BLUE:
+				return "Blue channel";
+			case TRANSPARENCY_MASK:
+				return "Transparency mask";
+			case USER_SUPPLIED_LAYER_MASK:
+				return "User supplied layer mask";
+			case REAL_USER_SUPPLIED_LAYER_MASK:
+				return "real user supplied layer mask (when both a user mask and a vector mask are present)";
+			default:
+				return "Unknown channel (value " + id + ")";
+		}
+	}
+}
diff --git a/src/pixy/meta/adobe/ColorSpaceID.java b/src/pixy/meta/adobe/ColorSpaceID.java
new file mode 100644
index 0000000..5fd0ad9
--- /dev/null
+++ b/src/pixy/meta/adobe/ColorSpaceID.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * ColorSpaceID.java - Adobe Photoshop color space IDs
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    28Jul2015  Initial creation
+ */
+
+package pixy.meta.adobe;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public enum ColorSpaceID {
+	//They are full unsigned 16-bit values as in Apple's RGBColor data structure. Pure red = 65535, 0, 0.
+	RGB("RGB: The first three values in the color data are red, green, and blue", 0),
+	//They are full unsigned 16-bit values as in Apple's HSVColor data structure. Pure red = 0,65535, 65535.
+	HSB("HSB:  The first three values in the color data are hue, saturation, and brightness", 1),
+	//They are full unsigned 16-bit values. 0 = 100% ink. For example, pure cyan = 0,65535,65535,65535.
+	CMYK("CMYK: The four values in the color data are cyan, magenta, yellow, and black", 2),	 
+	PANTONE("Pantone matching system", 3), // Custom color space
+	FOCOLTONE("Focoltone colour system", 4), // Custom color space
+	TRUMATCH("Trumatch color", 5), // Custom color space
+	TOYO88("Toyo 88 colorfinder 1050", 6),
+	//Lightness is a 16-bit value from 0...10000. Chrominance components are each 16-bit values from -12800...12700.
+	//Gray values are represented by chrominance components of 0. Pure white = 10000,0,0.
+	Lab("Lab: The first three values in the color data are lightness, a chrominance, and b chrominance", 7), 
+	Grayscale("Grayscale: The first value in the color data is the gray value, from 0...10000", 8),
+	HKS("HKS colors", 10),
+
+	UNKNOWN("Unknown", 99);
+	
+	private ColorSpaceID(String description, int value) {
+		this.description = description;
+		this.value = value;
+	}
+	
+	public String getDescription() {
+		return description;
+	}
+	
+	public int getValue() {
+		return value;
+	}
+	
+	public static ColorSpaceID fromInt(int value) {
+       	ColorSpaceID id = idMap.get(value);
+    	if (id == null)
+    		return UNKNOWN;
+   		return id;
+    }
+	
+	private static final Map<Integer, ColorSpaceID> idMap = new HashMap<Integer, ColorSpaceID>();
+       
+    static
+    {
+      for(ColorSpaceID id : values()) {
+           idMap.put(id.getValue(), id);
+      }
+    }
+	
+	private final String description;
+	private final int value;
+}
diff --git a/src/pixy/meta/adobe/DDB.java b/src/pixy/meta/adobe/DDB.java
new file mode 100644
index 0000000..444f8da
--- /dev/null
+++ b/src/pixy/meta/adobe/DDB.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * DDB.java - Adobe Photoshop Document Data Block
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    23Jul2015  Initial creation
+ */
+
+package pixy.meta.adobe;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.meta.Metadata;
+import pixy.meta.MetadataEntry;
+import pixy.meta.MetadataType;
+import pixy.io.IOUtils;
+import pixy.io.ReadStrategy;
+import pixy.util.ArrayUtils;
+
+public class DDB extends Metadata {
+	private ReadStrategy readStrategy;
+	private Map<Integer, DDBEntry> entries = new HashMap<Integer, DDBEntry>();
+	// DDB unique ID
+	public static final String DDB_ID = "Adobe Photoshop Document Data Block\0";
+	public static final int _8BIM = 0x3842494d; // "8BIM"
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(DDB.class);
+	
+	public static void showDDB(byte[] data, ReadStrategy readStrategy) {
+		if(data != null && data.length > 0) {
+			DDB ddb = new DDB(data, readStrategy);
+			try {
+				ddb.read();
+				ddb.showMetadata();
+			} catch (IOException e) {
+				e.printStackTrace();
+			}			
+		}
+	}
+	
+	public static void showDDB(InputStream is, ReadStrategy readStrategy) {
+		try {
+			showDDB(IOUtils.inputStreamToByteArray(is), readStrategy);
+		} catch (IOException e) {
+			e.printStackTrace();
+		}
+	}
+	
+	public DDB(byte[] data, ReadStrategy readStrategy) {
+		super(MetadataType.PHOTOSHOP_DDB, data);
+		if(readStrategy == null) throw new IllegalArgumentException("Input readStategy is null");
+		this.readStrategy = readStrategy;
+	}
+	
+	public Map<Integer, DDBEntry> getEntries() {
+		return Collections.unmodifiableMap(entries);
+	}
+	
+	public Iterator<MetadataEntry> iterator() {
+		ensureDataRead();
+		List<MetadataEntry> entries = new ArrayList<MetadataEntry>();
+
+		for(DDBEntry entry : this.entries.values())
+			entries.add(entry.getMetadataEntry());
+		
+		return Collections.unmodifiableCollection(entries).iterator();
+	}
+	
+	public void read() throws IOException {
+		if(!isDataRead) {
+			int i = 0;
+			if(!new String(data, i, DDB_ID.length()).equals(DDB_ID)) {
+				throw new RuntimeException("Invalid Photoshop Document Data Block");
+			}
+			i += DDB_ID.length();
+			while((i+4) < data.length) {
+				int signature = readStrategy.readInt(data, i);
+				i += 4;
+				if(signature ==_8BIM) {
+					int type = readStrategy.readInt(data, i);
+					i += 4;
+					int size = readStrategy.readInt(data, i);
+					i += 4;
+					DataBlockType etype = DataBlockType.fromInt(type);
+					switch(etype) {
+						case Layr:
+							entries.put(type, new LayerData(size, ArrayUtils.subArray(data, i, size), readStrategy));
+							break;
+						case LMsk:
+							entries.put(type, new UserMask(size, ArrayUtils.subArray(data, i, size), readStrategy));
+							break;
+						case FMsk:
+							entries.put(type, new FilterMask(size, ArrayUtils.subArray(data, i, size), readStrategy));
+							break;
+						default:
+							entries.put(type, new DDBEntry(type, size, ArrayUtils.subArray(data, i, size), readStrategy));
+					}
+					i += ((size + 3)>>2)<<2;// Skip data with padding bytes (padded to a 4 byte offset)
+				}
+			}
+			isDataRead = true;
+		}
+	}
+	
+	public void showMetadata() {
+		ensureDataRead();
+		LOGGER.info("<<Adobe DDB information starts>>");
+		for(DDBEntry entry : entries.values()) {
+			entry.print();
+		}
+		LOGGER.info("<<Adobe DDB information ends>>");
+	}
+}
diff --git a/src/pixy/meta/adobe/DDBEntry.java b/src/pixy/meta/adobe/DDBEntry.java
new file mode 100644
index 0000000..c540c38
--- /dev/null
+++ b/src/pixy/meta/adobe/DDBEntry.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * DDBEntry.java
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================================
+ * WY    24Jul2015  initial creation
+ */
+
+package pixy.meta.adobe;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.io.ReadStrategy;
+import pixy.meta.MetadataEntry;
+import pixy.string.StringUtils;
+
+//Building block for DDB
+public class DDBEntry {
+	private int type;
+	private int size;
+	protected byte[] data;
+	protected ReadStrategy readStrategy;
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(DDBEntry.class);
+
+	public DDBEntry(DataBlockType etype, int size, byte[] data, ReadStrategy readStrategy) {
+		this(etype.getValue(), size, data, readStrategy);
+	}
+	
+	public DDBEntry(int type, int size, byte[] data, ReadStrategy readStrategy) {
+		this.type = type;
+		if(size < 0) throw new IllegalArgumentException("Input size is negative");
+		this.size = size;
+		this.data = data;
+		if(readStrategy == null) throw new IllegalArgumentException("Input readStrategy is null");
+		this.readStrategy = readStrategy;
+	}
+
+	public void print() {
+		DataBlockType etype = getTypeEnum();
+		if(etype != DataBlockType.UNKNOWN)
+			LOGGER.info("Type: {} ({})", etype, etype.getDescription());
+		else
+			LOGGER.info("Type: Unknown (value 0x{})", Integer.toHexString(type));
+		LOGGER.info("Size: {}", size);	
+	}
+	
+	public int getType() {
+		return type;
+	}
+	
+	public DataBlockType getTypeEnum() {
+		return DataBlockType.fromInt(type);
+	}
+	
+	protected MetadataEntry getMetadataEntry() {
+		//	
+		DataBlockType eType  = DataBlockType.fromInt(type);
+		
+		if (eType == DataBlockType.UNKNOWN) {
+			return new MetadataEntry("UNKNOWN [" + StringUtils.intToHexStringMM(type) + "]:", eType.getDescription());
+		} else {
+			return new MetadataEntry("" + eType, eType.getDescription());
+		}
+	}
+	
+	public int getSize() {
+		return size;
+	}
+	
+	public byte[] getData() {
+		return data.clone();
+	}
+}
diff --git a/src/pixy/meta/adobe/DataBlockType.java b/src/pixy/meta/adobe/DataBlockType.java
new file mode 100644
index 0000000..537b304
--- /dev/null
+++ b/src/pixy/meta/adobe/DataBlockType.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * DataBlockType.java - Adobe Photoshop Document Data Block Types
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    25Jul2015  Initial creation
+ */
+
+package pixy.meta.adobe;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public enum DataBlockType {
+	Layr("Layer Data", 0x4c617972),
+	LMsk("User Mask Same as Global layer mask info table", 0x4c4d736b),
+	Patt("Pattern", 0x50617474),
+	FMsk("Filter Mask", 0x464d736b),
+	Anno("Annotations", 0x416e6e6f),
+	
+	UNKNOWN("Unknown Data Block", 0xFFFFFFFF);
+	
+	private DataBlockType(String description, int value) {
+		this.description = description;
+		this.value = value;
+	}
+	
+	public String getDescription() {
+		return description;
+	}
+	
+	public int getValue() {
+		return value;
+	}
+	
+	public static DataBlockType fromInt(int value) {
+       	DataBlockType type = typeMap.get(value);
+    	if (type == null)
+    		return UNKNOWN;
+   		return type;
+    }
+	
+	private static final Map<Integer, DataBlockType> typeMap = new HashMap<Integer, DataBlockType>();
+       
+    static
+    {
+      for(DataBlockType type : values()) {
+           typeMap.put(type.getValue(), type);
+      }
+    }
+	
+ 	private final String description;
+	private final int value;
+}
diff --git a/src/pixy/meta/adobe/FilterMask.java b/src/pixy/meta/adobe/FilterMask.java
new file mode 100644
index 0000000..c17fc95
--- /dev/null
+++ b/src/pixy/meta/adobe/FilterMask.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * FilterMask.java - Adobe Photoshop Document Data Block LMsk
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    28Jul2015  Initial creation
+ */
+
+package pixy.meta.adobe;
+
+import java.util.Arrays;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.io.ReadStrategy;
+
+public class FilterMask extends DDBEntry {
+	private int colorSpaceId;
+	private int[] colors = new int[4];
+	private int opacity;
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(FilterMask.class);
+	
+	public FilterMask(int size, byte[] data, ReadStrategy readStrategy) {
+		super(DataBlockType.FMsk, size, data, readStrategy);
+		read();
+	}
+	
+	public int[] getColors() {
+		return colors.clone();
+	}
+	
+	public int getOpacity() {
+		return opacity;
+	}
+
+	public int getColorSpace() {
+		return colorSpaceId;
+	}
+	
+	public ColorSpaceID getColorSpaceID() {
+		return ColorSpaceID.fromInt(colorSpaceId);
+	}
+	
+	public void print() {
+		super.print();
+		LOGGER.info("Color space: {}", getColorSpaceID());
+		LOGGER.info("Color values: {}", Arrays.toString(colors));
+		LOGGER.info("Opacity: {}", opacity);
+	}
+	
+	private void read() {
+		int i = 0;
+		colorSpaceId = readStrategy.readShort(data, i);
+		i += 2;
+		colors[0] = readStrategy.readUnsignedShort(data, i);
+		i += 2;
+		colors[1] = readStrategy.readUnsignedShort(data, i);
+		i += 2;
+		colors[2] = readStrategy.readUnsignedShort(data, i);
+		i += 2;
+		colors[3] = readStrategy.readUnsignedShort(data, i);
+		i += 2;
+		opacity = readStrategy.readShort(data, i);
+	}
+}
diff --git a/src/pixy/meta/adobe/IPTC_NAA.java b/src/pixy/meta/adobe/IPTC_NAA.java
new file mode 100644
index 0000000..4af609d
--- /dev/null
+++ b/src/pixy/meta/adobe/IPTC_NAA.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * IPTC_NAA.java
+ *
+ * Who   Date       Description
+ * ====  =========  ==================================================
+ * WY    25Apr2015  Added addDataSets()
+ * WY    25Apr2015  Renamed getDataSet(0 to getDataSets()
+ * WY    13Apr2015  Changed write() to use ITPC.write()
+ * WY    12Apr2015  Removed unnecessary read()
+ */
+
+package pixy.meta.adobe;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.Map.Entry;
+
+import pixy.meta.MetadataEntry;
+import pixy.meta.adobe.ImageResourceID;
+import pixy.meta.adobe._8BIM;
+import pixy.meta.iptc.IPTC;
+import pixy.meta.iptc.IPTCDataSet;
+import pixy.meta.iptc.IPTCTag;
+import pixy.string.StringUtils;
+
+public class IPTC_NAA extends _8BIM {
+	//
+	private IPTC iptc;
+		
+	public IPTC_NAA() {
+		this("IPTC_NAA");
+	}
+	
+	public IPTC_NAA(String name) {
+		super(ImageResourceID.IPTC_NAA, name, null);
+		iptc = new IPTC();
+	}
+
+	public IPTC_NAA(String name, byte[] data) {
+		super(ImageResourceID.IPTC_NAA, name, data);
+		iptc = new IPTC(data);
+	}
+	
+	public void addDataSet(IPTCDataSet dataSet) {
+		iptc.addDataSet(dataSet);
+	}
+	
+	public void addDataSets(Collection<? extends IPTCDataSet> dataSets) {
+		iptc.addDataSets(dataSets);
+	}
+	
+	/**
+	 * Get all the IPTCDataSet as a map for this IPTC data
+	 * 
+	 * @return a map with the key for the IPTCDataSet name and a list of IPTCDataSet as the value
+	 */
+	public Map<IPTCTag, List<IPTCDataSet>> getDataSets() {
+		return iptc.getDataSets();			
+	}
+	
+	/**
+	 * Get a list of IPTCDataSet associated with a key
+	 * 
+	 * @param key name of the data set
+	 * @return a list of IPTCDataSet associated with the key
+	 */
+	public List<IPTCDataSet> getDataSet(IPTCTag key) {
+		return iptc.getDataSet(key);
+	}
+	
+	protected MetadataEntry getMetadataEntry() {
+		//
+		ImageResourceID eId  = ImageResourceID.fromShort(getID());
+		MetadataEntry entry = new MetadataEntry(eId.name(), eId.getDescription(), true);
+		
+		Map<IPTCTag, List<IPTCDataSet>> datasetMap = this.getDataSets();
+		
+		if(datasetMap != null) {
+			// Print multiple entry IPTCDataSet
+			Set<Map.Entry<IPTCTag, List<IPTCDataSet>>> entries = datasetMap.entrySet();
+			
+			for(Entry<IPTCTag, List<IPTCDataSet>> entryMap : entries) {
+				StringBuilder strBuilder = new StringBuilder();
+				//
+				for(IPTCDataSet item : entryMap.getValue())
+					strBuilder.append(item.getDataAsString()).append(";");
+				
+				String key = entryMap.getKey().getName();				
+				String value = StringUtils.replaceLast(strBuilder.toString(), ";", "");
+				
+				entry.addEntry(new MetadataEntry(key, value));
+		    }
+			
+			return entry;
+			
+		} else 
+			return super.getMetadataEntry();
+	}
+	
+	public void write(OutputStream os) throws IOException {
+		if(data == null) {			
+			ByteArrayOutputStream bout = new ByteArrayOutputStream();
+			iptc.write(bout);
+			data = bout.toByteArray();
+			size = data.length;
+		}
+		super.write(os);
+	}
+}
diff --git a/src/pixy/meta/adobe/IRB.java b/src/pixy/meta/adobe/IRB.java
new file mode 100644
index 0000000..d29c550
--- /dev/null
+++ b/src/pixy/meta/adobe/IRB.java
@@ -0,0 +1,228 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * IRB.java
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    14Apr2015  Added getThumbnailResource()
+ * WY    10Apr2015  Added containsThumbnail() and getThumbnail()
+ * WY    13Mar2015  Initial creation
+ */
+
+package pixy.meta.adobe;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.meta.Metadata;
+import pixy.meta.MetadataEntry;
+import pixy.meta.MetadataType;
+import pixy.meta.adobe._8BIM;
+import pixy.util.ArrayUtils;
+import pixy.io.IOUtils;
+
+public class IRB extends Metadata {
+	private boolean containsThumbnail;
+	private ThumbnailResource thumbnail;
+	Map<Short, _8BIM> _8bims = new HashMap<Short, _8BIM>();
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(IRB.class);
+	
+	public static void showIRB(byte[] data) {
+		if(data != null && data.length > 0) {
+			IRB irb = new IRB(data);
+			try {
+				irb.read();
+				irb.showMetadata();
+			} catch (IOException e) {
+				e.printStackTrace();
+			}			
+		}
+	}
+	
+	public static void showIRB(InputStream is) {
+		try {
+			showIRB(IOUtils.inputStreamToByteArray(is));
+		} catch (IOException e) {
+			e.printStackTrace();
+		}
+	}
+	
+	public IRB(byte[] data) {
+		super(MetadataType.PHOTOSHOP_IRB, data);
+	}
+	
+	public Iterator<MetadataEntry> iterator() {
+		ensureDataRead();
+		List<MetadataEntry> items = new ArrayList<MetadataEntry>();
+
+		for(_8BIM _8bim : _8bims.values())
+			items.add(_8bim.getMetadataEntry());
+	
+		if(containsThumbnail) {
+			int thumbnailFormat = thumbnail.getDataType(); //1 = kJpegRGB. Also supports kRawRGB (0).
+			switch (thumbnailFormat) {
+				case IRBThumbnail.DATA_TYPE_KJpegRGB:
+					items.add(new MetadataEntry("Thumbnail Format: ", "DATA_TYPE_KJpegRGB"));
+					break;
+				case IRBThumbnail.DATA_TYPE_KRawRGB:
+					items.add(new MetadataEntry("Thumbnail Format: ", "DATA_TYPE_KRawRGB"));
+					break;
+			}
+			items.add(new MetadataEntry("Thumbnail width:", "" + thumbnail.getWidth()));
+			items.add(new MetadataEntry("Thumbnail height: ", "" + thumbnail.getHeight()));
+			// Padded row bytes = (width * bits per pixel + 31) / 32 * 4.
+			items.add(new MetadataEntry("Thumbnail Padded row bytes:  ", "" + thumbnail.getPaddedRowBytes()));
+			// Total size = widthbytes * height * planes
+			items.add(new MetadataEntry("Thumbnail Total size: ", "" + thumbnail.getTotalSize()));
+			// Size after compression. Used for consistency check.
+			items.add(new MetadataEntry("Thumbnail Size after compression: ", "" + thumbnail.getCompressedSize()));
+			// Bits per pixel. = 24
+			items.add(new MetadataEntry("Thumbnail Bits per pixel: ", "" + thumbnail.getBitsPerPixel()));
+			// Number of planes. = 1
+			items.add(new MetadataEntry("Thumbnail Number of planes: ", "" + thumbnail.getNumOfPlanes()));
+		}
+	
+		return Collections.unmodifiableList(items).iterator();
+	}
+	
+	public boolean containsThumbnail() {
+		ensureDataRead();
+		return containsThumbnail;
+	}
+	
+	public Map<Short, _8BIM> get8BIM() {
+		ensureDataRead();
+		return Collections.unmodifiableMap(_8bims);
+	}
+	
+	public _8BIM get8BIM(short tag) {
+		ensureDataRead();
+		return _8bims.get(tag);
+	}
+	
+	public IRBThumbnail getThumbnail()  {
+		ensureDataRead();
+		return thumbnail.getThumbnail();
+	}
+	
+	public ThumbnailResource getThumbnailResource() {
+		ensureDataRead();
+		return thumbnail;
+	}
+	
+	public void read() throws IOException {
+		if(!isDataRead) {
+			int i = 0;
+			while((i+4) < data.length) {
+				String _8bim = new String(data, i, 4);
+				i += 4;			
+				if(_8bim.equals("8BIM")) {
+					short id = IOUtils.readShortMM(data, i);
+					i += 2;
+					// Pascal string for name follows
+					// First byte denotes string length -
+					int nameLen = data[i++]&0xff;
+					if((nameLen%2) == 0) nameLen++;
+					String name = new String(data, i, nameLen).trim();
+					i += nameLen;
+					//
+					int size = IOUtils.readIntMM(data, i);
+					i += 4;
+					
+					if(size <= 0) continue; //Fix bug with zero size 8BIM
+					
+					ImageResourceID eId = ImageResourceID.fromShort(id); 
+					
+					switch(eId) {
+						case JPEG_QUALITY:
+							_8bims.put(id, new JPGQuality(name, ArrayUtils.subArray(data, i, size)));
+							break;
+						case VERSION_INFO:
+							_8bims.put(id, new VersionInfo(name, ArrayUtils.subArray(data, i, size)));
+							break;
+						case IPTC_NAA:
+							byte[] newData = ArrayUtils.subArray(data, i, size);
+							_8BIM iptcBim = _8bims.get(id);
+							if(iptcBim != null) {
+								byte[] oldData = iptcBim.getData();
+								_8bims.put(id, new IPTC_NAA(name, ArrayUtils.concat(oldData, newData)));
+							} else
+								_8bims.put(id, new IPTC_NAA(name, newData));
+							break;
+						case THUMBNAIL_RESOURCE_PS4:
+						case THUMBNAIL_RESOURCE_PS5:
+							containsThumbnail = true;
+							thumbnail = new ThumbnailResource(eId, ArrayUtils.subArray(data, i, size));
+							_8bims.put(id, thumbnail);
+							break;
+						default:
+							_8bims.put(id, new _8BIM(id, name, size, ArrayUtils.subArray(data, i, size)));
+					}				
+					
+					i += size;
+					if(size%2 != 0) i++; // Skip padding byte
+				}
+			}
+			isDataRead = true;
+		}
+	}
+	
+	public void showMetadata() {
+		ensureDataRead();
+		LOGGER.info("<<Adobe IRB information starts>>");
+		for(_8BIM _8bim : _8bims.values()) {
+			_8bim.print();
+		}
+		if(containsThumbnail) {
+			LOGGER.info("{}", thumbnail.getResouceID());
+			int thumbnailFormat = thumbnail.getDataType(); //1 = kJpegRGB. Also supports kRawRGB (0).
+			switch (thumbnailFormat) {
+				case IRBThumbnail.DATA_TYPE_KJpegRGB:
+					LOGGER.info("Thumbnail format: KJpegRGB");
+					break;
+				case IRBThumbnail.DATA_TYPE_KRawRGB:
+					LOGGER.info("Thumbnail format: KRawRGB");
+					break;
+			}
+			LOGGER.info("Thumbnail width: {}", thumbnail.getWidth());
+			LOGGER.info("Thumbnail height: {}", thumbnail.getHeight());
+			// Padded row bytes = (width * bits per pixel + 31) / 32 * 4.
+			LOGGER.info("Padded row bytes: {}", thumbnail.getPaddedRowBytes());
+			// Total size = widthbytes * height * planes
+			LOGGER.info("Total size: {}", thumbnail.getTotalSize());
+			// Size after compression. Used for consistency check.
+			LOGGER.info("Size after compression: {}", thumbnail.getCompressedSize());
+			// Bits per pixel. = 24
+			LOGGER.info("Bits per pixel: {}", thumbnail.getBitsPerPixel());
+			// Number of planes. = 1
+			LOGGER.info("Number of planes: {}", thumbnail.getNumOfPlanes());
+		}
+		
+		LOGGER.info("<<Adobe IRB information ends>>");
+	}
+}
diff --git a/src/pixy/meta/adobe/IRBThumbnail.java b/src/pixy/meta/adobe/IRBThumbnail.java
new file mode 100644
index 0000000..87736ce
--- /dev/null
+++ b/src/pixy/meta/adobe/IRBThumbnail.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * IRBThumbnail.java
+ *
+ * Who   Date       Description
+ * ====  =========  ===========================================================
+ * WY    27Apr2015  Added copy constructor
+ * WY    10Apr2015  Implemented base class Thumbnail abstract method write()
+ * WY    13Mar2015  Initial creation for IRBReader to encapsulate IRB thumbnail
+ */
+
+package pixy.meta.adobe;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import android.graphics.Bitmap;
+import pixy.meta.Thumbnail;
+
+/** 
+ * Photoshop Image Resource Block thumbnail wrapper.
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com 
+ * @version 1.0 01/10/2015   
+ */
+public class IRBThumbnail extends Thumbnail {
+	
+	public IRBThumbnail() { ; }
+	
+	public IRBThumbnail(Bitmap thumbnail) {
+		super(thumbnail);
+	}
+	
+	public IRBThumbnail(int width, int height, int dataType, byte[] compressedThumbnail) {
+		super(width, height, dataType, compressedThumbnail);
+	}
+	
+	public IRBThumbnail(IRBThumbnail other) { // Copy constructor
+		this.dataType = other.dataType;
+		this.height = other.height;
+		this.width = other.width;
+		this.thumbnail = other.thumbnail;
+		this.compressedThumbnail = other.compressedThumbnail;
+	}
+
+	@Override
+	public void write(OutputStream os) throws IOException {
+		if(getDataType() == Thumbnail.DATA_TYPE_KJpegRGB) { // Compressed old-style JPEG format
+			os.write(getCompressedImage());
+		} else if(getDataType() == Thumbnail.DATA_TYPE_KRawRGB) {
+			Bitmap thumbnail = getRawImage();
+			if(thumbnail == null) throw new IllegalArgumentException("Expected raw data thumbnail does not exist!");
+			try {
+				thumbnail.compress(Bitmap.CompressFormat.JPEG, writeQuality, os);
+			} catch (Exception e) {
+				throw new RuntimeException("Unable to compress thumbnail as JPEG");
+			}			
+		}
+	}
+ }
diff --git a/src/pixy/meta/adobe/ImageResourceID.java b/src/pixy/meta/adobe/ImageResourceID.java
new file mode 100644
index 0000000..c6cdc73
--- /dev/null
+++ b/src/pixy/meta/adobe/ImageResourceID.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * ImageResourceID.java
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    13Mar2015  initial creation
+ */
+
+package pixy.meta.adobe;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import pixy.meta.adobe.ImageResourceID;
+import pixy.string.StringUtils;
+
+/**
+ * Defines Image Resource IDs for Adobe Image Resource Block (IRB)
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 06/10/2013
+ */
+public enum ImageResourceID {
+	// Adobe Image Resource Block IDs - JPEG APP13
+	CHANNELS_ROWS_COLUMNS_DEPTH_MODE("Obsolete. Photoshop 2.0 only. Contains five 2 byte values: number of channels, rows, columns, depth, and mode.", (short)0x03e8),
+	PRINT_MANAGER_INFO("Optional. Macintosh print manager print info record.", (short)0x03e9),
+	XML("XML data.", (short)0x03ea),
+	INDEXED_COLOR_TABLE("Obsolete. Photoshop 2.0 only. Contains the indexed color table.", (short)0x03eb),
+	RESOLUTION_INFO("ResolutionInfo structure.", (short)0x03ed),
+	ALPHA_CHANNEL_NAMES("Names of the alpha channels as a series of Pascal strings.", (short)0x03ee),
+	DISPLAY_INFO("DisplayInfo structure.", (short)0x03ef),
+	CAPTION("Optional. The caption as a Pascal string.", (short)0x03f0),
+	BORDER_INFO("Border information. Contains a fixed-number for the border width, and 2 bytes for border units (1=inches, 2=cm, 3=points, 4=picas, 5=columns).", (short)0x03f1),
+	BACKGROUND_COLOR("Background color.", (short)0x03f2),
+	PRINT_FLAGS("Print flags. A series of one byte boolean values: labels, crop marks, color bars, registration marks, negative, flip, interpolate, caption.", (short)0x03f3),
+	GRAYSCALE_INFO("Grayscale and multichannel halftoning information.", (short)0x03f4),	
+	COLOR_HALFTONING_INFO("Color halftoning information.", (short)0x03f5),
+	DUOTONE_HALFTONING_INFO("Duotone halftoning information.", (short)0x03f6),
+	GRAYSCALE_FUNCTION("Grayscale and multichannel transfer function.", (short)0x03f7),
+	COLOR_FUNCTION("Color transfer functions.", (short)0x03f8),
+	DUOTONE_FUNCTION("Duotone transfer functions.", (short)0x03f9),
+	DUOTONE_IMAGE_INFO("Duotone image information.", (short)0x03fa),
+	EFFECTIVE_BW_VALUES("Two bytes for the effective black and white values for the dot range.", (short)0x03fb),
+	OBSOLETE1("Obsolete.", (short)0x03fc),
+	EPS_OPTIONS("EPS options.", (short)0x03fd),
+	QUICK_MASK_INFO("Quick Mask information. 2 bytes containing Quick Mask channel ID, 1 byte boolean indicating whether the mask was initially empty.", (short)0x03fe),
+	OBSOLETE2("Obsolete.", (short)0x03ff),
+	LAYER_STATE_INFO("Layer state information. 2 bytes containing the index of target layer. 0=bottom layer.", (short)0x0400),
+	WORKING_PATH("Working path (not saved).", (short)0x0401),
+	LAYERS_GROUP_INFO("Layers group information. 2 bytes per layer containing a group ID for the dragging groups. Layers in a group have the same group ID.", (short)0x0402),
+	OBSOLETE3("Obsolete.", (short)0x0403),
+	IPTC_NAA("IPTC-NAA record.", (short)0x0404),
+	IMAGE_MODE("Image mode for raw format files.", (short)0x0405),
+	JPEG_QUALITY("JPEG quality. Private.", (short)0x0406),
+	GRID_INFO("Grid and guides information.", (short)0x0408),
+	THUMBNAIL_RESOURCE_PS4("Photoshop 4.0 thumbnail resource.", (short)0x0409), // Photoshop 4.0
+	COPYRIGHT_FLAG("Copyright flag. Boolean indicating whether image is copyrighted. Can be set via Property suite or by user in File Info...", (short)0x040a),
+	URL("URL. Handle of a text string with uniform resource locator. Can be set via Property suite or by user in File Info...", (short)0x040b),
+	THUMBNAIL_RESOURCE_PS5("Photoshop 5.0 thumbnail resource.", (short)0x040c), // Photoshop 5.0
+	GLOBLE_ANGLE("Global Angle. 4 bytes that contain an integer between 0..359 which is the global lighting angle for effects layer. If not present assumes 30.", (short)0x040d),
+	COLOR_SAMPLERS_RESOURCE("Color samplers resource. See color samplers resource format later in this chapter.", (short)0x040e),
+	ICC_PROFILE("ICC Profile. The raw bytes of an ICC format profile.", (short)0x040f),
+	WATERMARK("One byte for Watermark.", (short)0x0410),
+	ICC_UNTAGGED("ICC Untagged. 1 byte that disables any assumed profile handling when opening the file. 1 = intentionally untagged.", (short)0x0411),
+	EFFECTS_VISIBLE("Effects visible. 1 byte global flag to show/hide all the effects layer. Only present when they are hidden.", (short)0x0412),
+	SPOT_HALFTONE("Spot Halftone. 4 bytes for version, 4 bytes for length, and the variable length data.", (short)0x0413),
+	DOC_SPECIFIC_ID("Document specific IDs, layer IDs will be generated starting at this base value or a greater value if we find existing IDs to already exceed it. 4 bytes.", (short)0x0414),
+	UNICODE_ALPHA_NAMES("Unicode Alpha Names. 4 bytes for length and the string as a unicode string.", (short)0x0415),
+	INDEXED_COLOR_TABLE_COUNT("Indexed Color Table Count. 2 bytes for the number of colors in table that are actually defined", (short)0x0416),
+	TRANSPARENT_INDEX("Tansparent Index. 2 bytes for the index of transparent color, if any.", (short)0x0417),
+	GLOBLE_ALTITUDE("Global Altitude. 4 byte entry for altitude.", (short)0x0419),	
+	SLICES("Slices.", (short)0x041a),
+	WORKFLOW_URL("Workflow URL. Unicode string, 4 bytes of length followed by unicode string.", (short)0x041b),
+	JUMP_TO_XPEP("Jump To XPEP. 2 bytes major version, 2 bytes minor version, 4 bytes count.", (short)0x041c),
+	ALPHA_IDENTIFIERS("Alpha Identifiers. 4 bytes of length, followed by 4 bytes each for every alpha identifier.", (short)0x041d),
+	URL_LIST("URL List. 4 byte count of URLs, followed by 4 byte long, 4 byte ID, and unicode string for each count.", (short)0x041e),
+	VERSION_INFO("(Photoshop 6.0) Version Info. 4 byte version, 1 byte HasRealMergedData, unicode string of writer name, unicode string of reader name, 4 bytes of file version.", (short)0x0421),
+	EXIF_DATA1("(Photoshop 7.0) EXIF data 1.", (short)0x0422),
+	EXIF_DATA3("(Photoshop 7.0) EXIF data 3.", (short)0x0423),
+	XMP_METADATA("(Photoshop 7.0) XMP metadata.", (short)0x0424),
+	CAPTION_DIGEST("(Photoshop 7.0) Caption digest. 16 bytes: RSA Data Security, MD5 message-digest algorithm.", (short)0x0425),
+	PRINT_SCALE("(Photoshop 7.0) Print scale. 2 bytes style (0 = centered, 1 = size to fit, 2 = user defined). 4 bytes x location (floating point). 4 bytes y location (floating point). 4 bytes scale (floating point).", (short)0x0426),
+	PIXEL_ASPECT_RATIO("(Photoshop CS) Pixel Aspect Ratio. 4 bytes (version = 1 or 2), 8 bytes double, x / y of a pixel. Version 2, attempting to correct values for NTSC and PAL, previously off by a factor of approx. 5%.", (short)0x0428),
+	LAYER_COMPS("(Photoshop CS) Layer Comps. 4 bytes (descriptor version = 16).", (short)0x0429),
+	ALTERNATE_DUOTONE_COLORS("(Photoshop CS) Alternate Duotone Colors.  bytes (version = 1), 2 bytes count, following is repeated for each count: [ Color: 2 bytes for space followed by 4 * 2 byte color component ], following this is another 2 byte count, usually 256, followed by Lab colors one byte each for L, a, b.", (short)0x042a),
+	ALTERNATE_SPOT_COLORS("(Photoshop CS)Alternate Spot Colors. 2 bytes (version = 1), 2 bytes channel count, following is repeated for each count: 4 bytes channel ID, Color: 2 bytes for space followed by 4 * 2 byte color component", (short)0x042b),
+	LAYER_SELECTION_IDS("(Photoshop CS2) Layer Selection ID(s). 2 bytes count, following is repeated for each count: 4 bytes layer ID.", (short)0x042d),
+	HDR_TONING_INFO("(Photoshop CS2) HDR Toning information.", (short)0x042e),
+	PRINT_INFO("(Photoshop CS2) Print info.", (short)0x042f),
+	LAYER_GROUP_ENABLED_ID("(Photoshop CS2) Layer Group(s) Enabled ID. 1 byte for each layer in the document, repeated by length of the resource. NOTE: Layer groups have start and end markers.", (short)0x0430),
+	COLOR_SAMPLERS_RESOURCE_CS3("(Photoshop CS3) Color samplers resource. Also see ID 1038 for old format.", (short)0x0431),
+	MEASUREMENT_SCALE("(Photoshop CS3) Measurement Scale. 4 bytes (descriptor version = 16), Descriptor.", (short)0x0432),
+	TIMELINE_INFO("(Photoshop CS3) Timeline Information. 4 bytes (descriptor version = 16).", (short)0x0433),
+	SHEET_DISCLOSURE("(Photoshop CS3) Sheet Disclosure. 4 bytes (descriptor version = 16).", (short)0x0434),
+	DISPLAY_INFO_STRUCTURE("(Photoshop CS3) DisplayInfo structure to support floating point clors. Also see ID 1007.", (short)0x0435),
+	ONION_SKINS("(Photoshop CS3) Onion Skins. 4 bytes (descriptor version = 16).", (short)0x0436),
+	COUNT_INFO("(Photoshop CS4) Count Information. 4 bytes (descriptor version = 16).", (short)0x0438),
+	PRINT_INFO_CS5("(Photoshop CS5) Print Information. 4 bytes (descriptor version = 16).", (short)0x043a),
+	PRINT_STYLE("(Photoshop CS5) Print Style. 4 bytes (descriptor version = 16).", (short)0x043b),
+	MACINTOSH_NS_PRINT_INFO("(Photoshop CS5) Macintosh NSPrintInfo. Variable OS specific info for Macintosh. NSPrintInfo. It is recommened that you do not interpret or use this data.", (short)0x043c),
+	WINDOWS_DEVMODE("(Photoshop CS5) Windows DEVMODE. Variable OS specific info for Windows. DEVMODE. It is recommened that you do not interpret or use this data.", (short)0x043d),
+	AUTO_SAVE_FILE_PATH("(Photoshop CS6) Auto Save File Path. Unicode string. It is recommened that you do not interpret or use this data.", (short)0x043e),
+	AUTO_SAVE_FORMAT("(Photoshop CS6) Auto Save Format. Unicode string. It is recommened that you do not interpret or use this data.", (short)0x043f),
+	// 0x07d0-0x0bb8 (2000-3000)
+	PATH_INFO0("Path Information (saved paths).", (short)0x07d0),
+	PATH_INFO998("Path Information (saved paths).", (short)0x0bb6),
+	CLIPPING_PATH_NAME("Name of clipping path.", (short)0x0bb7),
+	ORIGIN_PATH_INFO("Origin path info.", (short)0x0bb8),
+	// 0x0fa0-0x1387 (4000-4999)
+	PLUGIN_RESOURCE0("Plug-In resource.", (short)0x0fa0),
+	PLUGIN_RESOURCE999("Plug-In resource.", (short)0x1387),
+	
+	IMAGEREADY_VARIABLES("Image Ready variables. XML representation of variables definition.", (short)0x1b58),
+	IMAGEREADY_DATASETS("Image Ready data sets.", (short)0x1b59),
+	
+	LIGHTROOM_WORKFLOW("(Photoshop CS3) Lightroom workflow, if present the document is in the middle of a Lightroom workflow.", (short)0x1f40),
+	
+	PRINT_FLAGS_INFO("Print flags information. 2 bytes version (=1), 1 byte center crop marks, 1 byte (=0), 4 bytes bleed width value, 2 bytes bleed width scale.", (short)0x2710),
+	
+	// unknown tag
+	UNKNOWN("Unknown",  (short)0xffff); 
+  	
+	private ImageResourceID(String description, short value)
+	{
+		this.description = description;
+		this.value = value;
+	}
+	
+	public String getDescription() {
+		return description;
+	}
+	
+	public short getValue() {
+		return value;
+	}
+	
+	@Override
+    public String toString() {
+		if (this == UNKNOWN)
+			return name();
+		return name() + " [Value: " + StringUtils.shortToHexStringMM(value) +"] - " + description;
+	}
+	
+    public static ImageResourceID fromShort(short value) {
+       	ImageResourceID id = idMap.get(value);
+    	if (id == null)
+    		return UNKNOWN;
+   		return id;
+    }
+    
+    private static final Map<Short, ImageResourceID> idMap = new HashMap<Short, ImageResourceID>();
+       
+    static
+    {
+      for(ImageResourceID id : values()) {
+           idMap.put(id.getValue(), id);
+      }
+    }
+    
+ 	private final String description;
+	private final short value;
+}
diff --git a/src/pixy/meta/adobe/JPGQuality.java b/src/pixy/meta/adobe/JPGQuality.java
new file mode 100644
index 0000000..4b603dc
--- /dev/null
+++ b/src/pixy/meta/adobe/JPGQuality.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * JPGQuality.java
+ *
+ * Who   Date       Description
+ * ====  =========  ==================================================
+ * WY    16Apr2015  Changed int constants to enums
+ */
+
+package pixy.meta.adobe;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.io.IOUtils;
+
+public class JPGQuality extends _8BIM {
+	public enum Format {
+		FORMAT_STANDARD(0x0000),
+		FORMAT_OPTIMISED(0x0001),
+		FORMAT_PROGRESSIVE(0x0101);
+		
+		private final int value;
+		
+		private Format(int value) {
+			this.value= value;
+		}
+		
+		public int getValue() {
+			return value;
+		}
+	}
+	public enum ProgressiveScans {
+		PROGRESSIVE_3_SCANS(0x0001),
+		PROGRESSIVE_4_SCANS(0x0002),
+		PROGRESSIVE_5_SCANS(0x0003);
+		
+		private final int value;
+		
+		private ProgressiveScans(int value) {
+			this.value= value;
+		}
+			
+		public int getValue() {
+			return value;
+		}		
+	}
+	public enum Quality {
+		QUALITY_1_LOW(0xfffd),
+		QUALITY_2_LOW(0xfffe),
+		QUALITY_3_LOW(0xffff),
+		QUALITY_4_LOW(0x0000),
+		QUALITY_5_MEDIUM(0x0001),
+		QUALITY_6_MEDIUM(0x0002),
+		QUALITY_7_MEDIUM(0x0003),
+		QUALITY_8_HIGH(0x0004),
+		QUALITY_9_HIGH(0x0005),
+		QUALITY_10_MAXIMUM(0x0006),
+		QUALITY_11_MAXIMUM(0x0007),
+		QUALITY_12_MAXIMUM(0x0008);		
+		
+		private final int value;
+		
+		private Quality(int value) {
+			this.value= value;
+		}
+		
+		public int getValue() {
+			return value;
+		}	
+	}
+	// Default values
+	private int quality = Quality.QUALITY_5_MEDIUM.getValue();	
+	private int format = Format.FORMAT_STANDARD.getValue();;	
+	private int progressiveScans = ProgressiveScans.PROGRESSIVE_3_SCANS.getValue();
+	private byte trailer = 0x01;
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(JPGQuality.class);
+	
+	public JPGQuality() {
+		this("JPGQuality");
+	}
+	
+	public JPGQuality(String name) {
+		super(ImageResourceID.JPEG_QUALITY, name, null);
+	}
+	
+	public JPGQuality(String name, byte[] data) {
+		super(ImageResourceID.JPEG_QUALITY, name, data);
+		read();
+	}
+	
+	public JPGQuality(Quality quality, Format format, ProgressiveScans progressiveScans) {
+		this("JPGQuality", quality, format, progressiveScans);
+	}
+	
+	public JPGQuality(String name, Quality quality, Format format, ProgressiveScans progressiveScans) {
+		super(ImageResourceID.JPEG_QUALITY, name, null);
+		// Null check
+		if(quality == null || format == null || progressiveScans == null)
+			throw new IllegalArgumentException("Input parameter(s) is null");
+		this.quality = quality.getValue();
+		this.format = format.getValue();
+		this.progressiveScans = progressiveScans.getValue();
+	}
+	
+	public int getFormat() {
+		return format;
+	}
+	
+	public String getFormatAsString() {
+		String retVal = "";
+		
+		switch (format) {
+			case 0x0000:
+				retVal = "Standard Format";
+				break;
+			case 0x0001:
+				retVal= "Optimised Format";
+				break;
+			case 0x0101:
+				retVal = "Progressive Format";
+				break;
+			default:
+		}
+		
+		return retVal;
+	}
+	
+	public int getProgressiveScans() {
+		return progressiveScans;
+	}
+	
+	public String getProgressiveScansAsString() {
+		String retVal = "";
+		
+		switch (progressiveScans) {
+			case 0x0001:
+				retVal = "3 Scans";
+				break;
+			case 0x0002:
+				retVal = "4 Scans";
+				break;
+			case 0x0003:
+				retVal = "5 Scans";
+				break;
+			default:
+		}
+		
+		return retVal;
+	}
+	
+	public int getQuality() {
+		return quality;
+	}
+
+	public String getQualityAsString() {
+		String retVal = "";
+		
+		switch (quality) {
+			case 0xfffd:
+				retVal = "Quality 1 (Low)";
+				break;
+			case 0xfffe:
+				retVal ="Quality 2 (Low)";
+				break;
+			case 0xffff:
+				retVal = "Quality 3 (Low)";
+				break;
+			case 0x0000:
+				retVal = "Quality 4 (Low)";
+				break;
+			case 0x0001:
+				retVal = "Quality 5 (Medium)";
+				break;
+			case 0x0002:
+				retVal = "Quality 6 (Medium)";
+				break;
+			case 0x0003:
+				retVal = "Quality 7 (Medium)";
+				break;
+			case 0x0004:
+				retVal= "Quality 8 (High)";
+				break;
+			case 0x0005:
+				retVal = "Quality 9 (High)";
+				break;
+			case 0x0006:
+				retVal = "Quality 10 (Maximum)";
+				break;
+			case 0x0007:
+				retVal = "Quality 11 (Maximum)";
+				break;
+			case 0x0008:
+				retVal = "Quality 12 (Maximum)";
+				break;
+			default:
+		}
+		
+		return retVal;
+	}
+	
+	public void print() {
+		super.print();
+		LOGGER.info("{} : {} : {} - Plus 1 byte unknown trailer value = {}", getQualityAsString(), getFormatAsString(), getProgressiveScansAsString(), trailer);
+	}
+	
+	private void read() {
+		// PhotoShop Save As Quality
+		// index 0: Quality level
+		quality = IOUtils.readUnsignedShortMM(data, 0);
+		format = IOUtils.readUnsignedShortMM(data, 2);
+		progressiveScans = IOUtils.readUnsignedShortMM(data, 4);	
+		trailer = data[6];// Always seems to be 0x01
+	}
+	
+	public void setFormat(Format format) {
+		if(format == null) throw new IllegalArgumentException("Input format is null");
+		this.format = format.getValue();
+	}
+	
+	public void setProgressiveScans(ProgressiveScans progressiveScans) {
+		if(progressiveScans == null) throw new IllegalArgumentException("Input progressive scans is null");
+		this.progressiveScans = progressiveScans.getValue();
+	}
+	
+	public void setQuality(Quality quality) {
+		if(quality == null) throw new IllegalArgumentException("Input quality is null");
+		this.quality = quality.getValue();
+	}
+	
+	public void write(OutputStream os) throws IOException {
+		if(data == null) {
+			data = new byte[7];
+			data[0] = (byte)((quality>>8)&0xff);
+			data[1] = (byte)(quality&0xff);
+			data[2] = (byte)((format>>8)&0xff);
+			data[3] = (byte)(format&0xff);
+			data[4] = (byte)((progressiveScans>>8)&0xff);
+			data[5] = (byte)(progressiveScans&0xff);
+			data[6] = trailer;
+			size = data.length;
+		}
+		super.write(os);
+	}
+}
diff --git a/src/pixy/meta/adobe/LayerData.java b/src/pixy/meta/adobe/LayerData.java
new file mode 100644
index 0000000..024685f
--- /dev/null
+++ b/src/pixy/meta/adobe/LayerData.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * LayerData.java
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================================
+ * WY    27Jul2015  initial creation
+ */
+
+package pixy.meta.adobe;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.io.ReadStrategy;
+
+public class LayerData extends DDBEntry {
+	private int layerCount;
+	private List<Channel> channels = new ArrayList<Channel>();
+		
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(LayerData.class);
+	
+	public LayerData(int size, byte[] data, ReadStrategy readStrategy) {
+		super(DataBlockType.Layr, size, data, readStrategy);
+		read();
+	}
+
+	public void print() {
+		super.print();
+		LOGGER.info("Number of layers: {}", layerCount);
+	}
+	
+	@SuppressWarnings("unused")
+	private void read() {
+		int i = 0;
+		layerCount = readStrategy.readUnsignedShort(data, i);
+		i += 2;
+		for(int j = 0; j < layerCount; j++) { // For each layer
+			int topCoord = readStrategy.readInt(data, i);
+			i += 4;
+			int leftCoord = readStrategy.readInt(data, i);
+			i += 4;
+			int bottomCoord = readStrategy.readInt(data, i);
+			i += 4;
+			int rightCoord = readStrategy.readInt(data, i);
+			i += 4;
+			int channelCount = readStrategy.readUnsignedShort(data, i);
+			i += 2;
+			for(int k = 0; k < channelCount; k++) {
+				int id = readStrategy.readShort(data, i);
+				i += 2;
+				int len = readStrategy.readInt(data, i);
+				i += 4;
+				channels.add(new Channel(id, len));
+			}
+			int blendModeSignature = readStrategy.readInt(data, i);
+			i += 4;
+			int blendMode = readStrategy.readInt(data, i);
+			i += 4;
+			int opacity = data[i++]&0xff;
+			int clipping = data[i++]&0xff;
+			int flags = data[i++]&0xff;
+			int filler = data[i++]&0xff;
+			int extraLen = readStrategy.readInt(data, i);
+			i += 4;
+			i += extraLen; // Skip the extra data for now
+			// TODO: read the following structure:
+			//Layer mask data
+			//Layer blending ranges
+			//Layer name: Pascal string, padded to a multiple of 4 bytes
+			//Additional layer information (optional)
+		}	
+	}
+}
diff --git a/src/pixy/meta/adobe/Slice.java b/src/pixy/meta/adobe/Slice.java
new file mode 100644
index 0000000..97e9790
--- /dev/null
+++ b/src/pixy/meta/adobe/Slice.java
@@ -0,0 +1,35 @@
+package pixy.meta.adobe;
+
+@SuppressWarnings("unused")
+public class Slice {	
+	private int id;
+	private int groupId;
+	private int origin;
+	private int associatedLayerId;
+	private String name; // Unicode string	 	
+	private int type;
+	private int left;
+	private int top;
+	private int right;
+	private int bottom;
+	private String URL; // Unicode string
+	private String target; // Unicode string
+	private String message; // Unicode string
+	private String altTag;// Unicode string
+	private boolean isCellTextHTML;
+	private String cellText; // Unicode string
+	private int horiAlignment;
+	private int vertAlignment;
+	private int alpha;
+	private int red;
+	private int green;
+	private int blue;
+	private byte[] extraData;
+	private int descriptorVersion;
+	private Descriptor descriptor;
+	
+	public static final class Descriptor {
+		private String name; // Unicode string
+		private int numOfItems;
+	}
+}
diff --git a/src/pixy/meta/adobe/Slices.java b/src/pixy/meta/adobe/Slices.java
new file mode 100644
index 0000000..56c310b
--- /dev/null
+++ b/src/pixy/meta/adobe/Slices.java
@@ -0,0 +1,51 @@
+package pixy.meta.adobe;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+
+import pixy.meta.adobe.ImageResourceID;
+import pixy.meta.adobe.Slice;
+import pixy.meta.adobe._8BIM;
+
+public class Slices extends _8BIM {
+	List<Slice> slices;
+	
+	public Slices() {
+		this("Slices");
+	}
+	
+	public Slices(String name) {
+		super(ImageResourceID.SLICES, name, null);
+	}
+
+	public Slices(String name, byte[] data) {
+		super(ImageResourceID.SLICES, name, data);
+		read();
+	}
+	
+	public List<Slice> getSlices() {
+		return slices;
+	}
+	
+	public void print() {
+		super.print();
+	
+	}
+
+	private void read() {
+		
+	}
+	
+	public void write(OutputStream os) throws IOException {
+		if(data == null) {
+		
+			size = data.length;
+		}
+		super.write(os);
+	}
+	
+	public static final class SliceHeader {
+		
+	}
+}
diff --git a/src/pixy/meta/adobe/ThumbnailResource.java b/src/pixy/meta/adobe/ThumbnailResource.java
new file mode 100644
index 0000000..620f5c0
--- /dev/null
+++ b/src/pixy/meta/adobe/ThumbnailResource.java
@@ -0,0 +1,237 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * ThumbnailResource.java
+ * <p>
+ * Adobe thumbnail resource wrapper
+ *
+ * Who   Date       Description
+ * ====  =========  ===================================================
+ * WY    09Apr2016  Added new constructor
+ * WY    14Apr2015  Fixed a bug with super() call, changed data to null 
+ * WY    14Apr2015  Added new constructor
+ * WY    13Apr2015  Initial creation
+ */
+
+package pixy.meta.adobe;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+import android.graphics.Bitmap;
+import pixy.io.IOUtils;
+import pixy.meta.Thumbnail;
+import pixy.util.ArrayUtils;
+import pixy.util.MetadataUtils;
+
+public class ThumbnailResource extends _8BIM {
+	// Check to make sure id is either ImageResourceID.THUMBNAIL_RESOURCE_PS4
+	// or ImageResourceID.THUMBNAIL_RESOURCE_PS5
+	private static ImageResourceID validateID(ImageResourceID id) {
+		if(id != ImageResourceID.THUMBNAIL_RESOURCE_PS4 && id != ImageResourceID.THUMBNAIL_RESOURCE_PS5)
+			throw new IllegalArgumentException("Unsupported thumbnail ImageResourceID: " + id);
+		
+		return id;
+	}	
+	// Fields
+	private int width;
+	private int height;
+	//Padded row bytes = (width * bits per pixel + 31) / 32 * 4.
+	private int paddedRowBytes;
+	// Total size = widthbytes * height * planes
+	private int totalSize;
+	// Size after compression. Used for consistency check.
+	private int compressedSize;
+	// Bits per pixel. = 24
+	private int bitsPerPixel;
+	// Number of planes. = 1
+	private int numOfPlanes;
+	private ImageResourceID id;
+	private int dataType;
+	// Thumbnail
+	private IRBThumbnail thumbnail = new IRBThumbnail();
+	
+	public ThumbnailResource(Bitmap thumbnail) {
+		this("THUMBNAIL_RESOURCE", thumbnail);
+	}
+		
+	public ThumbnailResource(String name, Bitmap thumbnail) {
+		super(ImageResourceID.THUMBNAIL_RESOURCE_PS5, name, null);
+		try {
+			this.thumbnail = createThumbnail(thumbnail);
+		} catch (IOException e) {
+			throw new RuntimeException("Unable to create IRBThumbnail from Bitmap");
+		}
+	}
+	
+	// id is either ImageResourceID.THUMBNAIL_RESOURCE_PS4 or ImageResourceID.THUMBNAIL_RESOURCE_PS5
+	public ThumbnailResource(ImageResourceID id, int dataType, int width, int height, byte[] thumbnailData) {
+		super(validateID(id), "THUMBNAIL_RESOURCE", null);
+		// Initialize fields
+		this.id = id;
+		this.dataType = dataType;
+		/** Sometimes, we don't have information about width and height */
+		this.width = (width > 0)? width : 0; 
+		this.height = (height > 0)? height : 0;
+		// paddedRowBytes = (width * bitsPerPixel + 31) / 32 * 4.
+		// totalSize = paddedRowBytes * height * numOfPlanes
+		this.paddedRowBytes = (width * 24 + 31)/32 * 4;
+		this.totalSize = paddedRowBytes * height * numOfPlanes;
+		this.compressedSize = thumbnailData.length;
+		this.bitsPerPixel = 24;
+		this.numOfPlanes = 1;
+		setThumbnailImage(id, dataType, width, height, totalSize, thumbnailData);
+	}
+	
+	// id is either ImageResourceID.THUMBNAIL_RESOURCE_PS4 or ImageResourceID.THUMBNAIL_RESOURCE_PS5
+	public ThumbnailResource(ImageResourceID id, byte[] data) {
+		super(validateID(id), "THUMBNAIL_RESOURCE", data);
+		this.id = id;
+		read();
+	}
+	
+	public ThumbnailResource(ImageResourceID id,Thumbnail thumbnail) {
+		this(id, thumbnail.getDataType(), thumbnail.getWidth(), thumbnail.getHeight(), thumbnail.getCompressedImage());
+	}
+	
+	private IRBThumbnail createThumbnail(Bitmap thumbnail) throws IOException {
+		// Create memory buffer to write data
+		ByteArrayOutputStream bout = new ByteArrayOutputStream();
+		// Compress the thumbnail
+		try {
+			thumbnail.compress(Bitmap.CompressFormat.JPEG, 100, bout);
+		} catch (Exception e) {
+			e.printStackTrace();
+		}
+		byte[] data = bout.toByteArray();
+		this.id = ImageResourceID.THUMBNAIL_RESOURCE_PS5;
+		// Write thumbnail dimension
+		this.width = thumbnail.getWidth();
+		this.height = thumbnail.getHeight();
+		// Padded row bytes = (width * bits per pixel + 31) / 32 * 4.
+		this.bitsPerPixel = 24;
+		this.numOfPlanes = 1;
+		this.paddedRowBytes = (width*bitsPerPixel + 31)/32*4;
+		// Total size = widthbytes * height * planes
+		this.totalSize = paddedRowBytes*height*numOfPlanes;
+		// Size after compression. Used for consistency check.
+		this.compressedSize = data.length;
+		this.dataType = Thumbnail.DATA_TYPE_KJpegRGB;
+			
+		return new IRBThumbnail(width, height, dataType, data);
+	}
+	
+	public int getBitsPerPixel() {
+		return bitsPerPixel;
+	}
+	
+	public int getCompressedSize() {
+		return compressedSize;
+	}
+	
+	public int getDataType() {
+		return dataType;
+	}
+	
+	public int getHeight() {
+		return height;
+	}
+	
+	public int getNumOfPlanes() {
+		return numOfPlanes;
+	}
+	
+	public int getPaddedRowBytes() {
+		return paddedRowBytes;
+	}
+	
+	public ImageResourceID getResouceID() {
+		return id;
+	}
+	
+	public IRBThumbnail getThumbnail() {
+		return new IRBThumbnail(thumbnail);
+	}
+	
+	public int getTotalSize() {
+		return totalSize;		
+	}
+	
+	public int getWidth() {
+		return width;
+	}
+	
+	private void setThumbnailImage(ImageResourceID id, int dataType, int width, int height, int totalSize, byte[] thumbnailData) {
+		// JFIF data in RGB format. For resource ID 1033 (0x0409) the data is in BGR format.
+		if(dataType == Thumbnail.DATA_TYPE_KJpegRGB) {
+			thumbnail.setImage(width, height, dataType, thumbnailData);
+		} else if(dataType == Thumbnail.DATA_TYPE_KRawRGB) {
+			// kRawRGB - NOT tested yet!
+			int[] colors = null;
+			if(id == ImageResourceID.THUMBNAIL_RESOURCE_PS4)
+				colors = MetadataUtils.bgr2ARGB(thumbnailData);
+			else if(id == ImageResourceID.THUMBNAIL_RESOURCE_PS5)
+				colors = MetadataUtils.toARGB(thumbnailData);
+			thumbnail.setImage(Bitmap.createBitmap(colors, width, height, Bitmap.Config.ARGB_8888));
+		} else
+			throw new UnsupportedOperationException("Unsupported IRB thumbnail data type: " + dataType);
+	}
+	
+	private void read() {
+		this.dataType = IOUtils.readIntMM(data, 0); //1 = kJpegRGB. Also supports kRawRGB (0).
+		this.width = IOUtils.readIntMM(data, 4);
+		this.height = IOUtils.readIntMM(data, 8);
+		// Padded row bytes = (width * bits per pixel + 31) / 32 * 4.
+		this.paddedRowBytes = IOUtils.readIntMM(data, 12);
+		// Total size = widthbytes * height * planes
+		this.totalSize = IOUtils.readIntMM(data, 16);
+		// Size after compression. Used for consistency check.
+		this.compressedSize = IOUtils.readIntMM(data, 20);
+		this.bitsPerPixel = IOUtils.readShortMM(data, 24); // Bits per pixel. = 24
+		this.numOfPlanes = IOUtils.readShortMM(data, 26); // Number of planes. = 1
+		byte[] thumbnailData = null;
+		if(dataType == Thumbnail.DATA_TYPE_KJpegRGB)
+			thumbnailData = ArrayUtils.subArray(data, 28, compressedSize);
+		else if(dataType == Thumbnail.DATA_TYPE_KRawRGB)
+			thumbnailData = ArrayUtils.subArray(data, 28, totalSize);
+		setThumbnailImage(id, dataType, width, height, totalSize, thumbnailData);
+	}
+		
+	public void write(OutputStream os) throws IOException {
+		if(data == null) {			
+			ByteArrayOutputStream bout = new ByteArrayOutputStream();
+			thumbnail.write(bout);
+			byte[] compressedData = bout.toByteArray();
+			bout.reset();
+			// Write thumbnail format
+			IOUtils.writeIntMM(bout, dataType);
+			IOUtils.writeIntMM(bout, width);
+			IOUtils.writeIntMM(bout, height);
+			IOUtils.writeIntMM(bout, paddedRowBytes);
+			// Total size = widthbytes * height * planes
+			IOUtils.writeIntMM(bout, totalSize);
+			// Size after compression. Used for consistency check.
+			IOUtils.writeIntMM(bout, compressedData.length);
+			IOUtils.writeShortMM(bout, bitsPerPixel);
+			IOUtils.writeShortMM(bout, numOfPlanes);
+			bout.write(compressedData);
+			data = bout.toByteArray();
+			size = data.length;
+		}
+		super.write(os);
+	}	
+}
diff --git a/src/pixy/meta/adobe/UserMask.java b/src/pixy/meta/adobe/UserMask.java
new file mode 100644
index 0000000..b009019
--- /dev/null
+++ b/src/pixy/meta/adobe/UserMask.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * UserMask.java - Adobe Photoshop Document Data Block LMsk
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    28Jul2015  Initial creation
+ */
+
+package pixy.meta.adobe;
+
+import java.util.Arrays;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.io.ReadStrategy;
+
+public class UserMask extends DDBEntry {
+	private int colorSpaceId;
+	private int[] colors = new int[4];
+	private int opacity;
+	private int flag;
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(UserMask.class);
+	
+	public UserMask(int size, byte[] data, ReadStrategy readStrategy) {
+		super(DataBlockType.LMsk, size, data, readStrategy);
+		read();
+	}
+	
+	public int[] getColors() {
+		return colors.clone();
+	}
+	
+	public int getOpacity() {
+		return opacity;
+	}
+	
+	public int getFlag() {
+		return flag;
+	}
+	
+	public int getColorSpace() {
+		return colorSpaceId;
+	}
+	
+	public ColorSpaceID getColorSpaceID() {
+		return ColorSpaceID.fromInt(colorSpaceId);
+	}
+	
+	public void print() {
+		super.print();
+		LOGGER.info("Color space: {}", getColorSpaceID());
+		LOGGER.info("Color values: {}", Arrays.toString(colors));
+		LOGGER.info("Opacity: {}", opacity);
+		LOGGER.info("Flag: {}", flag);
+	}
+	
+	private void read() {
+		int i = 0;
+		colorSpaceId = readStrategy.readShort(data, i);
+		i += 2;
+		colors[0] = readStrategy.readUnsignedShort(data, i);
+		i += 2;
+		colors[1] = readStrategy.readUnsignedShort(data, i);
+		i += 2;
+		colors[2] = readStrategy.readUnsignedShort(data, i);
+		i += 2;
+		colors[3] = readStrategy.readUnsignedShort(data, i);
+		i += 2;
+		opacity = readStrategy.readShort(data, i);
+		i += 2;
+		flag = data[i]&0xff; // 128
+	}
+}
diff --git a/src/pixy/meta/adobe/VersionInfo.java b/src/pixy/meta/adobe/VersionInfo.java
new file mode 100644
index 0000000..055ce1a
--- /dev/null
+++ b/src/pixy/meta/adobe/VersionInfo.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * VersionInfo.java
+ * <p>
+ * Adobe IRB version info resource wrapper
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    17Apr2015  Added new constructor
+ * WY    17Apr2015  Changed version and fileVersion data type to int  
+ */
+
+package pixy.meta.adobe;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.meta.adobe.ImageResourceID;
+import pixy.meta.adobe._8BIM;
+import pixy.io.IOUtils;
+import pixy.string.StringUtils;
+
+public class VersionInfo extends _8BIM {
+	//
+	private int version;
+	private boolean hasRealMergedData;
+	private String writerName;
+	private String readerName;
+	private int fileVersion;
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(VersionInfo.class);
+	
+	public VersionInfo() {
+		this("VersionInfo");
+	}
+	
+	public VersionInfo(String name) {
+		super(ImageResourceID.VERSION_INFO, name, null);
+	}
+
+	public VersionInfo(String name, byte[] data) {
+		super(ImageResourceID.VERSION_INFO, name, data);
+		read();
+	}
+	
+	public VersionInfo(int version, boolean hasRealMergedData, String writerName, String readerName, int fileVersion) {
+		this("VersionInfo", version, hasRealMergedData, writerName, readerName, fileVersion);
+	}
+	
+	public VersionInfo(String name, int version, boolean hasRealMergedData, String writerName, String readerName, int fileVersion) {
+		super(ImageResourceID.VERSION_INFO, name, null);
+		this.version = version;
+		this.hasRealMergedData = hasRealMergedData;
+		this.writerName = writerName;
+		this.readerName = readerName;
+		this.fileVersion = fileVersion;		
+	}
+	
+	public int getFileVersion() {
+		return fileVersion;
+	}
+	
+	public int getVersion() {
+		return version;
+	}
+	
+	public boolean hasRealMergedData() {
+		return hasRealMergedData;
+	}
+	
+	public String getReaderName() {
+		return readerName;
+	}
+	
+	public String getWriterName() {
+		return writerName;
+	}
+	
+	private void read() {
+		int i = 0;
+		version = IOUtils.readIntMM(data, i);
+		i += 4;
+	    hasRealMergedData = ((data[i++]!=0)?true:false);
+	    int writer_size = IOUtils.readIntMM(data, i);
+	    i += 4;
+	    writerName = StringUtils.toUTF16BE(data, i, writer_size*2);
+	    i += writer_size*2;
+	    int reader_size = IOUtils.readIntMM(data, i);
+    	i += 4;
+    	readerName = StringUtils.toUTF16BE(data, i, reader_size*2);
+    	i += reader_size*2;
+	    fileVersion = IOUtils.readIntMM(data, i);  
+	}
+	
+	public void print() {
+		super.print();
+		LOGGER.info("Version: {}", getVersion());
+		LOGGER.info("Has Real Merged Data: {}", hasRealMergedData);
+        LOGGER.info("Writer name: {}", writerName);
+		LOGGER.info("Reader name: {}", readerName);
+		LOGGER.info("File Version: {}", getFileVersion()); 
+	}
+
+	public void setHasRealMergedData(boolean hasRealMergedData) {
+		this.hasRealMergedData = hasRealMergedData;
+	}
+	
+	public void setFileVersion(int fileVersion) {
+		if(fileVersion < 0)
+			throw new IllegalArgumentException("File version number is negative");
+		this.fileVersion = fileVersion;
+	}
+	
+	public void setVersion(int version) {
+		if(version < 0)
+			throw new IllegalArgumentException("Version number is negative");
+		this.version = version;
+	}
+	
+	public void setWriterName(String writerName) {
+		this.writerName = writerName;
+	}
+	
+	public void setReaderName(String readerName) {
+		this.readerName = readerName;
+	}
+	
+	public void write(OutputStream os) throws IOException {
+		if(data == null) {
+			ByteArrayOutputStream bout = new ByteArrayOutputStream();
+			IOUtils.writeIntMM(bout, version);
+			bout.write(hasRealMergedData?1:0);
+			byte[] writerNameBytes = null;
+			writerNameBytes = writerName.getBytes("UTF-16BE");
+			IOUtils.writeIntMM(bout, writerName.length());
+			bout.write(writerNameBytes);
+			byte[] readerNameBytes = null;
+			readerNameBytes = readerName.getBytes("UTF-16BE");
+			IOUtils.writeIntMM(bout, readerName.length());
+			bout.write(readerNameBytes);
+			IOUtils.writeIntMM(bout, fileVersion);
+			data = bout.toByteArray();
+			size = data.length;
+		}
+		super.write(os);
+	}
+}
diff --git a/src/pixy/meta/adobe/_8BIM.java b/src/pixy/meta/adobe/_8BIM.java
new file mode 100644
index 0000000..1c46a4a
--- /dev/null
+++ b/src/pixy/meta/adobe/_8BIM.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * _8BIM.java
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================================
+ * WY    13Mar2015  initial creation
+ */
+
+package pixy.meta.adobe;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.meta.MetadataEntry;
+import pixy.meta.adobe.ImageResourceID;
+import pixy.io.IOUtils;
+import pixy.string.StringUtils;
+
+public class _8BIM {
+	private short id;
+	private String name;
+	protected int size;
+	protected byte[] data;
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(_8BIM.class);
+	
+	public _8BIM(short id, String name, byte[] data) {
+		this(id, name, (data == null)?0:data.length, data);
+	}
+	
+	public _8BIM(short id, String name, int size, byte[] data) {
+		this.id = id;
+		this.name = name;
+		this.size = size;
+		this.data = data;
+	}
+	
+	public _8BIM(ImageResourceID eId, String name, byte[] data) {
+		this(eId.getValue(), name, data);
+	}
+	
+	public byte[] getData() {
+		return data.clone();
+	}
+	
+	// Default implementation to be override by sub-classes for iteration purpose
+	protected MetadataEntry getMetadataEntry() {
+		//	
+		ImageResourceID eId  = ImageResourceID.fromShort(id);
+		
+		if((id >= ImageResourceID.PATH_INFO0.getValue()) && (id <= ImageResourceID.PATH_INFO998.getValue())) {
+			return new MetadataEntry("PATH_INFO [" + StringUtils.shortToHexStringMM(id) + "]", eId.getDescription());
+		} else if((id >= ImageResourceID.PLUGIN_RESOURCE0.getValue()) && (id <= ImageResourceID.PLUGIN_RESOURCE999.getValue())) {
+			return new MetadataEntry("PLUGIN_RESOURCE [" + StringUtils.shortToHexStringMM(id) + "]", eId.getDescription());
+		} else if (eId == ImageResourceID.UNKNOWN) {
+			return new MetadataEntry("UNKNOWN [" + StringUtils.shortToHexStringMM(id) + "]", eId.getDescription());
+		} else {
+			return new MetadataEntry("" + eId, eId.getDescription());
+		}		
+	}
+	
+	public String getName() {
+		return name;
+	}
+	
+	public short getID() {
+		return id;
+	}
+	
+	public int getSize() {
+		return size;
+	}
+	
+	public void print() {
+		ImageResourceID eId  = ImageResourceID.fromShort(id);
+		
+		if((id >= ImageResourceID.PATH_INFO0.getValue()) && (id <= ImageResourceID.PATH_INFO998.getValue())) {
+			LOGGER.info("PATH_INFO [Value: {}] - Path Information (saved paths).", StringUtils.shortToHexStringMM(id));
+		}
+		else if((id >= ImageResourceID.PLUGIN_RESOURCE0.getValue()) && (id <= ImageResourceID.PLUGIN_RESOURCE999.getValue())) {
+			LOGGER.info("PLUGIN_RESOURCE [Value: {}] - Plug-In resource.", StringUtils.shortToHexStringMM(id));
+		}
+		else if (eId == ImageResourceID.UNKNOWN) {
+			LOGGER.info("{} [Value: {}]", eId, StringUtils.shortToHexStringMM(id));
+		}
+		else {
+			LOGGER.info("{}", eId);
+		}
+		
+		LOGGER.info("Type: 8BIM");
+		LOGGER.info("Name: {}", name);
+		LOGGER.info("Size: {}", size);	
+	}
+	
+	public void write(OutputStream os) throws IOException {
+		// Write IRB id
+		os.write("8BIM".getBytes());
+		// Write resource id
+		IOUtils.writeShortMM(os, id); 		
+		// Write name (Pascal string - first byte denotes length of the string)
+		byte[] temp = name.trim().getBytes();
+		os.write(temp.length); // Size of the string, may be zero
+		os.write(temp);
+		if(temp.length%2 == 0)
+			os.write(0);
+		// Now write data size
+		IOUtils.writeIntMM(os, size);
+		os.write(data); // Write the data itself
+		if(data.length%2 != 0)
+			os.write(0); // Padding the data to even size if needed
+	}
+}
diff --git a/src/pixy/meta/bmp/BMPMeta.java b/src/pixy/meta/bmp/BMPMeta.java
new file mode 100644
index 0000000..6fefa16
--- /dev/null
+++ b/src/pixy/meta/bmp/BMPMeta.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * BMPMeta.java
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    14Mar2015  Initial creation
+ */
+
+package pixy.meta.bmp;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
+
+import pixy.meta.Metadata;
+import pixy.meta.MetadataEntry;
+import pixy.meta.MetadataType;
+import pixy.meta.image.ImageMetadata;
+import pixy.image.bmp.BmpCompression;
+import pixy.io.IOUtils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * BMP image tweaking tool
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 12/29/2014
+ */
+public class BMPMeta {
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(BMPMeta.class);
+
+	// Data transfer object for multiple thread support
+	private static class DataTransferObject {
+		private byte[] fileHeader; // 14
+		private byte[] infoHeader; // 40	
+		private int[] colorPalette;
+	}
+	
+	private static void readHeader(InputStream is, DataTransferObject DTO) throws IOException {
+		DTO.fileHeader = new byte[14];
+		DTO.infoHeader = new byte[40];
+		
+		is.read(DTO.fileHeader);
+		is.read(DTO.infoHeader);
+	}
+	
+	public static Map<MetadataType, Metadata> readMetadata(InputStream is) throws IOException {
+		Map<MetadataType, Metadata> metadataMap = new HashMap<MetadataType, Metadata>();
+		ImageMetadata imageMeta = new ImageMetadata();
+		// Create a new data transfer object to hold data
+		DataTransferObject DTO = new DataTransferObject();
+		readHeader(is, DTO);
+				
+		LOGGER.info("... BMP Image Inforamtion starts...");
+		LOGGER.info("Image signature: {}", new String(DTO.fileHeader, 0, 2));
+		LOGGER.info("File size: {} bytes", IOUtils.readInt(DTO.fileHeader, 2));
+		LOGGER.info("Reserved1 (2 bytes): {}", IOUtils.readShort(DTO.fileHeader, 6));
+		LOGGER.info("Reserved2 (2 bytes): {}", IOUtils.readShort(DTO.fileHeader, 8));
+		LOGGER.info("Data offset: {}", IOUtils.readInt(DTO.fileHeader, 10));
+		
+		MetadataEntry header = new MetadataEntry("BMP File Header", "Bitmap File Header", true);
+		header.addEntry(new MetadataEntry("Image signature", new String(DTO.fileHeader, 0, 2)));
+		header.addEntry(new MetadataEntry("File size", IOUtils.readInt(DTO.fileHeader, 2) + " bytes"));
+		header.addEntry(new MetadataEntry("Reserved1", IOUtils.readShort(DTO.fileHeader, 6)+""));
+		header.addEntry(new MetadataEntry("Reserved2", IOUtils.readShort(DTO.fileHeader, 8)+""));
+		header.addEntry(new MetadataEntry("Data-offset", "byte " + IOUtils.readInt(DTO.fileHeader, 10)));
+		
+		imageMeta.addMetadataEntry(header);
+	
+		// TODO add more ImageMetadata elements
+		LOGGER.info("Info header length: {}", IOUtils.readInt(DTO.infoHeader, 0));
+		LOGGER.info("Image width: {}", IOUtils.readInt(DTO.infoHeader, 4));
+		LOGGER.info("Image heigth: {}", IOUtils.readInt(DTO.infoHeader, 8));
+		
+		String alignment = "";
+		if(IOUtils.readInt(DTO.infoHeader, 8) > 0)
+			alignment = "BOTTOM_UP" ;
+		else
+			alignment = "TOP_DOWN";
+		
+		MetadataEntry infoHeader = new MetadataEntry("BMP Info Header", "Bitmap Information Header", true);
+		infoHeader.addEntry(new MetadataEntry("Info-header-lengthen", IOUtils.readInt(DTO.infoHeader, 0) + " bytes"));
+		infoHeader.addEntry(new MetadataEntry("Image-alignment", alignment));
+		infoHeader.addEntry(new MetadataEntry("Number-of-planes", IOUtils.readShort(DTO.infoHeader, 12) + " planes"));
+		infoHeader.addEntry(new MetadataEntry("Bits-per-pixel", IOUtils.readShort(DTO.infoHeader, 14) + " bits per pixel"));
+		infoHeader.addEntry(new MetadataEntry("Compression", BmpCompression.fromInt(IOUtils.readInt(DTO.infoHeader, 16)).toString()));
+		infoHeader.addEntry(new MetadataEntry("Compessed-image-size", IOUtils.readInt(DTO.infoHeader, 20) + " bytes"));
+		infoHeader.addEntry(new MetadataEntry("Horizontal-resolution", IOUtils.readInt(DTO.infoHeader, 24) + " pixels/meter"));
+		infoHeader.addEntry(new MetadataEntry("Vertical-resolution", IOUtils.readInt(DTO.infoHeader, 28) + " pixels/meter"));
+		infoHeader.addEntry(new MetadataEntry("Colors-used", IOUtils.readInt(DTO.infoHeader, 32) + " colors used"));
+		infoHeader.addEntry(new MetadataEntry("Important-colors", IOUtils.readInt(DTO.infoHeader, 36) + " important colors"));
+	
+		imageMeta.addMetadataEntry(infoHeader);
+		
+		LOGGER.info("Image alignment: {}", alignment);
+		LOGGER.info("Number of planes: {}", IOUtils.readShort(DTO.infoHeader, 12));
+		LOGGER.info("BitCount (bits per pixel): {}", IOUtils.readShort(DTO.infoHeader, 14));
+		LOGGER.info("Compression: {}", BmpCompression.fromInt(IOUtils.readInt(DTO.infoHeader, 16)));
+		LOGGER.info("Image size (compressed size of image): {} bytes", IOUtils.readInt(DTO.infoHeader, 20));
+		LOGGER.info("Horizontal resolution (Pixels/meter): {}", IOUtils.readInt(DTO.infoHeader, 24));
+		LOGGER.info("Vertical resolution (Pixels/meter): {}", IOUtils.readInt(DTO.infoHeader, 28));
+		LOGGER.info("Colors used (number of actually used colors): {}", IOUtils.readInt(DTO.infoHeader, 32));
+		LOGGER.info("Important colors (number of important colors): {}", IOUtils.readInt(DTO.infoHeader, 36));		
+				
+		int bitsPerPixel = IOUtils.readShort(DTO.infoHeader, 14);
+		
+		if(bitsPerPixel <= 8) {
+			readPalette(is, DTO);
+			LOGGER.info("Color map follows");
+		}
+		
+		metadataMap.put(MetadataType.IMAGE, imageMeta);
+		
+		return metadataMap;		
+	}
+	
+	private static void readPalette(InputStream is, DataTransferObject DTO) throws IOException {
+		int index = 0, bindex = 0;
+		int colorsUsed = IOUtils.readInt(DTO.infoHeader, 32);
+		int bitsPerPixel = IOUtils.readShort(DTO.infoHeader, 14);
+		int dataOffset = IOUtils.readInt(DTO.fileHeader, 10);
+		int numOfColors = (colorsUsed == 0)?(1<<bitsPerPixel):colorsUsed;
+		byte palette[] = new byte[numOfColors*4];
+		DTO.colorPalette = new int[numOfColors];	
+     
+		IOUtils.readFully(is, palette);
+
+        for(int i = 0; i < numOfColors; i++)
+		{
+			DTO.colorPalette[index++] = ((0xff<<24)|(palette[bindex]&0xff)|((palette[bindex+1]&0xff)<<8)|((palette[bindex+2]&0xff)<<16));
+			bindex += 4;
+		}
+		// There may be some extra bytes between colorPalette and actual image data
+		IOUtils.skipFully(is, dataOffset - numOfColors*4 - 54);
+	}
+	
+	private BMPMeta() {}
+}
diff --git a/src/pixy/meta/exif/Exif.java b/src/pixy/meta/exif/Exif.java
new file mode 100644
index 0000000..0ea442c
--- /dev/null
+++ b/src/pixy/meta/exif/Exif.java
@@ -0,0 +1,391 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * Exif.java
+ *
+ * Who   Date       Description
+ * ====  =======    =================================================
+ * WY    10Apr2015  Moved data loaded checking to ExifReader
+ * WY    31Mar2015  Fixed bug with getImageIFD() etc
+ * WY    13Mar2015  Initial creation
+ */
+
+package pixy.meta.exif;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import android.graphics.Bitmap;
+import pixy.meta.Metadata;
+import pixy.meta.MetadataEntry;
+import pixy.meta.MetadataType;
+import pixy.meta.Thumbnail;
+import pixy.meta.tiff.TIFFMeta;
+import pixy.string.StringUtils;
+import pixy.image.tiff.FieldType;
+import pixy.image.tiff.IFD;
+import pixy.image.tiff.Tag;
+import pixy.image.tiff.TiffField;
+import pixy.image.tiff.TiffTag;
+import pixy.io.FileCacheRandomAccessInputStream;
+import pixy.io.FileCacheRandomAccessOutputStream;
+import pixy.io.IOUtils;
+import pixy.io.RandomAccessInputStream;
+import pixy.io.RandomAccessOutputStream;
+
+/**
+ * EXIF wrapper
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2014
+ */
+public abstract class Exif extends Metadata {
+	protected IFD imageIFD;
+	protected IFD exifSubIFD;
+	protected IFD gpsSubIFD;
+	protected IFD interopSubIFD;
+	protected ExifThumbnail thumbnail;
+	protected short preferredEndian = IOUtils.BIG_ENDIAN;
+	
+	private boolean containsThumbnail;
+	private boolean isThumbnailRequired;
+	
+	public static final int FIRST_IFD_OFFSET = 0x08;
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(Exif.class);
+	
+	public Exif() {
+		super(MetadataType.EXIF);
+		isDataRead = true;
+	}
+	
+	public Exif(byte[] data) {
+		super(MetadataType.EXIF, data);
+		ensureDataRead();
+	}
+	
+	public Exif(IFD imageIFD) {
+		this();
+		setImageIFD(imageIFD);
+	}
+	
+	public Exif(InputStream is) throws IOException {
+		this(IOUtils.inputStreamToByteArray(is));
+	}
+	
+	public void addExifField(ExifTag tag, FieldType type, Object data) {
+		if(exifSubIFD == null)
+			exifSubIFD = new IFD();
+		TiffField<?> field = FieldType.createField(tag, type, data);
+		if(field != null)
+			exifSubIFD.addField(field);
+		else
+			throw new IllegalArgumentException("Cannot create required EXIF TIFF field");
+	}
+	
+	public void addGPSField(GPSTag tag, FieldType type, Object data) {
+		if(gpsSubIFD == null)
+			gpsSubIFD = new IFD();
+		TiffField<?> field = FieldType.createField(tag, type, data);
+		if(field != null)
+			gpsSubIFD.addField(field);
+		else
+			throw new IllegalArgumentException("Cannot create required GPS TIFF field");
+	}
+	
+	public void addInteropField(InteropTag tag, FieldType type, Object data) {
+		if(interopSubIFD == null)
+			interopSubIFD = new IFD();
+		TiffField<?> field = FieldType.createField(tag, type, data);
+		if(field != null)
+			interopSubIFD.addField(field);
+		else
+			throw new IllegalArgumentException("Cannot create required interop TIFF field");
+	}
+	
+	public void addImageField(TiffTag tag, FieldType type, Object data) {
+		if(imageIFD == null)
+			imageIFD = new IFD();
+		TiffField<?> field = FieldType.createField(tag, type, data);
+		if(field != null)
+			imageIFD.addField(field);
+		else
+			throw new IllegalArgumentException("Cannot create required Image TIFF field");
+	}
+	
+	public boolean containsThumbnail() {
+		if(containsThumbnail)
+			return true;
+		if(thumbnail != null)
+			return true;
+		return false;
+	}
+	
+	public IFD getExifIFD() {
+		if(exifSubIFD != null) {
+			return new IFD(exifSubIFD);
+		}
+		
+		return null;
+	}
+	
+	public IFD getGPSIFD() {
+		if(gpsSubIFD != null) {
+			return new IFD(gpsSubIFD);
+		} 
+
+		return null;
+	}
+	
+	public IFD getInteropIFD() {
+		if(interopSubIFD != null) {
+			return new IFD(interopSubIFD);
+		} 
+
+		return null;
+	}
+	
+	public IFD getImageIFD() {
+		if(imageIFD != null) {
+			return new IFD(imageIFD);
+		}
+		
+		return null;		
+	}
+	
+	private void getMetadataEntries(IFD currIFD, Class<? extends Tag> tagClass, List<MetadataEntry> items) {
+		// Use reflection to invoke fromShort(short) method
+		Method method = null;
+		try {
+			method = tagClass.getDeclaredMethod("fromShort", short.class);
+		} catch (NoSuchMethodException e) {
+			throw new RuntimeException("Method 'fromShort' is not defined for class " + tagClass);
+		} catch (SecurityException e) {
+			throw new RuntimeException("The operation is not allowed by the current security setup");
+		}
+		
+		Collection<TiffField<?>> fields = currIFD.getFields();
+		MetadataEntry entry = null;
+		
+		if(tagClass.equals(TiffTag.class)) {
+			entry = new MetadataEntry("IFD0", "Image Info", true);
+		} else if(tagClass.equals(ExifTag.class)) {
+			entry = new MetadataEntry("EXIF SubIFD", "EXIF Info", true);
+		} else if(tagClass.equals(GPSTag.class)) {
+			entry = new MetadataEntry("GPS SubIFD", "GPS Info", true);
+		} else if(tagClass.equals(InteropTag.class)) {
+			entry = new MetadataEntry("Interoperability SubIFD", "Interoperability Info", true);
+		} else
+			entry = new MetadataEntry("UNKNOWN", "UNKNOWN SubIFD", true);
+		
+		for(TiffField<?> field : fields) {
+			short tag = field.getTag();
+			Tag ftag = TiffTag.UNKNOWN;
+			if(tag == ExifTag.PADDING.getValue()) {
+				ftag = ExifTag.PADDING;
+			} else if(tag == ExifTag.EXIF_INTEROPERABILITY_OFFSET.getValue()) {
+				ftag = ExifTag.EXIF_INTEROPERABILITY_OFFSET;
+			} else {
+				try {
+					ftag = (Tag)method.invoke(null, tag);
+				} catch (IllegalAccessException e) {
+					throw new RuntimeException("Illegal access for method: " + method);
+				} catch (IllegalArgumentException e) {
+					throw new RuntimeException("Illegal argument for method:  " + method);
+				} catch (InvocationTargetException e) {
+					throw new RuntimeException("Incorrect invocation target");
+				}
+			}	
+			if (ftag == TiffTag.UNKNOWN)
+				LOGGER.warn("Tag: {} [Value: 0x{}] (Unknown)", ftag, Integer.toHexString(tag&0xffff));
+			
+			FieldType ftype = field.getType();				
+						
+			String tagString = null;
+			if(ftype == FieldType.SHORT || ftype == FieldType.SSHORT)
+				tagString = ftag.getFieldAsString(field.getDataAsLong());
+			else
+				tagString = ftag.getFieldAsString(field.getData());
+			if(StringUtils.isNullOrEmpty(tagString))
+				entry.addEntry(new MetadataEntry(ftag.getName(), field.getDataAsString()));
+			else
+				entry.addEntry(new MetadataEntry(ftag.getName(), tagString));
+		}
+		
+		items.add(entry); // Add the Entry (group) into the collection
+		
+		Map<Tag, IFD> children = currIFD.getChildren();
+		
+		if(children.get(TiffTag.EXIF_SUB_IFD) != null) {
+			getMetadataEntries(children.get(TiffTag.EXIF_SUB_IFD), ExifTag.class, items);
+		}
+		
+		if(children.get(ExifTag.EXIF_INTEROPERABILITY_OFFSET) != null) {
+			getMetadataEntries(children.get(ExifTag.EXIF_INTEROPERABILITY_OFFSET), InteropTag.class, items);
+		}
+		
+		if(children.get(TiffTag.GPS_SUB_IFD) != null) {
+			getMetadataEntries(children.get(TiffTag.GPS_SUB_IFD), GPSTag.class, items);
+		}		
+	}
+
+	public ExifThumbnail getThumbnail() {
+		if(thumbnail != null)
+			return new ExifThumbnail(thumbnail);
+	
+		return null;
+	}
+	
+	public boolean isThumbnailRequired() {
+		return isThumbnailRequired;
+	}
+	
+	public short getPreferredEndian() {
+		return preferredEndian;
+	}
+	
+	public Iterator<MetadataEntry> iterator() {
+		ensureDataRead();
+		List<MetadataEntry> items = new ArrayList<MetadataEntry>();
+		if(imageIFD != null)
+			getMetadataEntries(imageIFD, TiffTag.class, items);
+		if(containsThumbnail) {
+			MetadataEntry thumbnailEntry = new MetadataEntry("IFD1", "Thumbnail Image", true);
+			thumbnailEntry.addEntry(new MetadataEntry("Thumbnail format", (thumbnail.getDataType() == 1? "DATA_TYPE_KJpegRGB":"DATA_TYPE_TIFF")));
+			thumbnailEntry.addEntry(new MetadataEntry("Thumbnail data length", "" + thumbnail.getCompressedImage().length));
+			items.add(thumbnailEntry);
+		}
+	
+		return Collections.unmodifiableList(items).iterator();
+	}
+	
+	public void read() throws IOException {
+		if(!isDataRead) {
+			RandomAccessInputStream exifIn = new FileCacheRandomAccessInputStream(new ByteArrayInputStream(data));
+			List<IFD> ifds = new ArrayList<IFD>(3);
+			TIFFMeta.readIFDs(ifds, exifIn);
+			
+			preferredEndian = exifIn.getEndian();
+			
+			if(ifds.size() > 0) {
+				imageIFD = ifds.get(0);
+				exifSubIFD = imageIFD.getChild(TiffTag.EXIF_SUB_IFD);
+				if(exifSubIFD != null)
+					interopSubIFD = exifSubIFD.getChild(ExifTag.EXIF_INTEROPERABILITY_OFFSET);
+				gpsSubIFD = imageIFD.getChild(TiffTag.GPS_SUB_IFD);
+			}
+		    // We have thumbnail IFD
+		    if(ifds.size() >= 2) {
+		    	IFD thumbnailIFD = ifds.get(1);
+		    	int width = -1;
+		    	int height = -1;
+		    	TiffField<?> field = thumbnailIFD.getField(TiffTag.IMAGE_WIDTH);
+		    	if(field != null) 
+		    		width = field.getDataAsLong()[0];
+		    	field = thumbnailIFD.getField(TiffTag.IMAGE_LENGTH);
+		    	if(field != null)
+		    		height = field.getDataAsLong()[0];
+		    	field = thumbnailIFD.getField(TiffTag.JPEG_INTERCHANGE_FORMAT);
+		    	if(field != null) { // JPEG format, save as JPEG
+		    		int thumbnailOffset = field.getDataAsLong()[0];
+		    		field = thumbnailIFD.getField(TiffTag.JPEG_INTERCHANGE_FORMAT_LENGTH);
+		    		int thumbnailLen = field.getDataAsLong()[0];
+		    		exifIn.seek(thumbnailOffset);
+		    		byte[] thumbnailData = new byte[thumbnailLen];
+		    		exifIn.readFully(thumbnailData);
+		    		thumbnail = new ExifThumbnail(width, height, Thumbnail.DATA_TYPE_KJpegRGB, thumbnailData, thumbnailIFD);
+		    		containsThumbnail = true;				    
+		    	} else { // Uncompressed TIFF
+		    		field = thumbnailIFD.getField(TiffTag.STRIP_OFFSETS);
+		    		if(field == null) 
+		    			field = thumbnailIFD.getField(TiffTag.TILE_OFFSETS);
+		    		if(field != null) {
+		    			 exifIn.seek(0);
+		    			 ByteArrayOutputStream bout = new ByteArrayOutputStream();
+		    			 RandomAccessOutputStream tiffout = new FileCacheRandomAccessOutputStream(bout);
+		    			 TIFFMeta.retainPages(exifIn, tiffout, 1);
+		    			 tiffout.close(); // Auto flush when closed
+		    			 thumbnail = new ExifThumbnail(width, height, Thumbnail.DATA_TYPE_TIFF, bout.toByteArray(), thumbnailIFD);
+		    			 containsThumbnail = true;		    			    
+		    		}
+		    	}
+		    }
+		    exifIn.close();		
+		    isDataRead = true;
+		}
+	}
+	
+	public void setExifIFD(IFD exifSubIFD) {
+		this.exifSubIFD = exifSubIFD;
+	}
+	
+	public void setGPSIFD(IFD gpsSubIFD) {
+		this.gpsSubIFD = gpsSubIFD;
+	}
+	
+	public void setInteropIFD(IFD interopSubIFD) {
+		this.interopSubIFD = interopSubIFD;
+	}
+	
+	public void setImageIFD(IFD imageIFD) {
+		if(imageIFD == null)
+			throw new IllegalArgumentException("Input image IFD is null");
+		this.imageIFD = imageIFD;
+		this.exifSubIFD = imageIFD.getChild(TiffTag.EXIF_SUB_IFD);
+		this.gpsSubIFD = imageIFD.getChild(TiffTag.GPS_SUB_IFD);
+	}
+	
+	/**
+	 * @param thumbnail a Thumbnail instance. If null, a thumbnail
+	 *        will be generated from the input image.
+	 */	
+	public void setThumbnail(ExifThumbnail thumbnail) {
+		this.thumbnail = thumbnail;
+	}
+	
+	public void setThumbnailImage(Bitmap thumbnail) {
+		if(this.thumbnail == null)
+			this.thumbnail = new ExifThumbnail(); 
+		this.thumbnail.setImage(thumbnail);
+	}
+	
+	public void setThumbnailRequired(boolean isThumbnailRequired) {
+		this.isThumbnailRequired = isThumbnailRequired;
+	}
+	
+	public void setPreferredEndian(short preferredEndian) {
+		if(preferredEndian != IOUtils.BIG_ENDIAN && preferredEndian != IOUtils.LITTLE_ENDIAN)
+			throw new IllegalArgumentException("Invalid Exif endian!");
+		this.preferredEndian = preferredEndian;
+	}
+		
+	public abstract void write(OutputStream os) throws IOException;
+}
diff --git a/src/pixy/meta/exif/ExifTag.java b/src/pixy/meta/exif/ExifTag.java
new file mode 100644
index 0000000..061493f
--- /dev/null
+++ b/src/pixy/meta/exif/ExifTag.java
@@ -0,0 +1,334 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.exif;
+
+import java.io.UnsupportedEncodingException;
+import java.text.DecimalFormat;
+import java.util.HashMap;
+import java.util.Map;
+
+import pixy.meta.exif.ExifTag;
+import pixy.image.tiff.FieldType;
+import pixy.image.tiff.Tag;
+import pixy.image.tiff.TiffTag;
+import pixy.string.StringUtils;
+
+/**
+ * Defines EXIF tags
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2015
+ */
+public enum ExifTag implements Tag {
+	EXPOSURE_TIME("ExposureTime", (short)0x829a),	
+	FNUMBER("FNumber", (short)0x829d) {
+		public String getFieldAsString(Object value) {
+			int[] intValues = (int[])value;
+			if(intValues.length != 2)
+				throw new IllegalArgumentException("Wrong number of EXIF FNumber data number: " + intValues.length);
+			//formatting numbers up to 2 decimal places in Java
+	        DecimalFormat df = new DecimalFormat("#,###,###.#");
+	        return "F" + StringUtils.rationalToString(df, true, intValues);	
+		}
+	},
+  	//EXIF_SUB_IFD("ExifSubIFD", (short)0x8769),	
+	EXPOSURE_PROGRAM("ExposureProgram", (short)0x8822),
+	SPECTRAL_SENSITIVITY("SpectralSensitivity", (short)0x8824),
+	ISO_SPEED_RATINGS("ISOSpeedRatings", (short)0x8827),
+	OECF("OECF", (short)0x8828),
+	
+	SENSITIVITY_TYPE("Sensitivity Type", (short)0x8830),
+	STANDARD_OUTPUT_SENSITIVITY("Standard Output Sensitivity", (short)0x8831),	
+	RECOMMENDED_EXPOSURE_INDEX("Recommended Exposure Index", (short)0x8832),	
+	
+	EXIF_VERSION("ExifVersion", (short)0x9000) {
+		public String getFieldAsString(Object value) {
+			return new String((byte[])value).trim();
+		}
+	},
+	DATE_TIME_ORIGINAL("DateTimeOriginal", (short)0x9003),
+	DATE_TIME_DIGITIZED("DateTimeDigitized", (short)0x9004),
+	
+	OFFSET_TIME("Offset Time", (short)0x9010),
+	OFFSET_TIME_ORIGINAL("Offset Time Original", (short)0x9011),
+	OFFSET_TIME_DIGITIZED("Offset Time Digitized", (short)0x9012),
+	
+	COMPONENT_CONFIGURATION("ComponentConfiguration", (short)0x9101),
+	COMPRESSED_BITS_PER_PIXEL("CompressedBitsPerPixel", (short)0x9102),
+	
+	SHUTTER_SPEED_VALUE("ShutterSpeedValue", (short)0x9201) {
+		public String getFieldAsString(Object value) {
+			int[] intValues = (int[])value;
+			if(intValues.length != 2)
+				throw new IllegalArgumentException("Wrong number of EXIF ShutterSpeedValue data number: " + intValues.length);
+			//formatting numbers up to 2 decimal places in Java
+	        DecimalFormat df = new DecimalFormat("#,###,###.##");
+	        return StringUtils.rationalToString(df, false, intValues);	
+		}
+	},
+	APERTURE_VALUE("ApertureValue", (short)0x9202),
+	BRIGHTNESS_VALUE("BrightValue", (short)0x9203),
+	EXPOSURE_BIAS_VALUE("ExposureBiasValue", (short)0x9204),
+	MAX_APERTURE_VALUE("MaxApertureValue", (short)0x9205),
+	SUBJECT_DISTANCE("SubjectDistance", (short)0x9206),
+	METERING_MODE("MeteringMode", (short)0x9207),
+	LIGHT_SOURCE("LightSource", (short)0x9208),
+	FLASH("Flash", (short)0x9209),	
+	FOCAL_LENGTH("FocalLength", (short)0x920a) {
+		public String getFieldAsString(Object value) {
+			int[] intValues = (int[])value;
+			if(intValues.length != 2)
+				throw new IllegalArgumentException("Wrong number of EXIF FocalLength data number: " + intValues.length);
+			//formatting numbers up to 2 decimal places in Java
+	        DecimalFormat df = new DecimalFormat("#,###,###.##");
+	        return StringUtils.rationalToString(df, true, intValues) + "mm";	
+		}
+	},	
+	SUBJECT_AREA("SubjectArea", (short)0x9214),	
+	MAKER_NOTE("MakerNote", (short)0x927c),
+	USER_COMMENT("UserComment", (short)0x9286),
+	
+	SUB_SEC_TIME("SubSecTime", (short)0x9290),
+	SUB_SEC_TIME_ORIGINAL("SubSecTimeOriginal", (short)0x9291),
+	SUB_SEC_TIME_DIGITIZED("SubSecTimeDigitized", (short)0x9292),
+	
+	WINDOWS_XP_TITLE("WindowsXP Title", (short) 0x9c9b) {
+		public String getFieldAsString(Object value) {
+			//
+			byte[] byteValue = (byte[]) value;
+			String description = "";
+			try {
+				description = new String(byteValue, "UTF-16LE").trim();
+			} catch (UnsupportedEncodingException e) {
+				e.printStackTrace();
+			}
+
+			return description;
+		}
+		public boolean isCritical() {
+			return false;
+		}
+	},
+	
+	WINDOWS_XP_COMMENT("WindowsXP Comment", (short)0x9c9c) {
+		public String getFieldAsString(Object value) {
+			//
+			byte[] byteValue = (byte[])value;
+			String description = "";
+			try {
+				description = new String(byteValue, "UTF-16LE").trim();
+			} catch (UnsupportedEncodingException e) {
+				e.printStackTrace();
+			}
+			
+			return description;
+		}
+		public boolean isCritical() {
+			return false;
+		}
+	},
+	WINDOWS_XP_AUTHOR("WindowsXP Author", (short)0x9c9d) {
+		public String getFieldAsString(Object value) {
+			//
+			byte[] byteValue = (byte[])value;
+			String description = "";
+			try {
+				description = new String(byteValue, "UTF-16LE").trim();
+			} catch (UnsupportedEncodingException e) {
+				e.printStackTrace();
+			}
+			
+			return description;
+		}
+		public boolean isCritical() {
+			return false;
+		}
+	},
+	
+	WINDOWS_XP_KEYWORDS("WindowsXP Keywords", (short)0x9c9e){
+		public String getFieldAsString(Object value) {
+			//
+			byte[] byteValue = (byte[])value;
+			String description = "";
+			try {
+				description = new String(byteValue, "UTF-16LE").trim();
+			} catch (UnsupportedEncodingException e) {
+				e.printStackTrace();
+			}
+			
+			return description;
+		}
+		public boolean isCritical() {
+			return false;
+		}
+	},
+	
+	WINDOWS_XP_SUBJECT("WindowsXP Subject", (short) 0x9c9f) {
+		public String getFieldAsString(Object value) {
+			//
+			byte[] byteValue = (byte[]) value;
+			String description = "";
+			try {
+				description = new String(byteValue, "UTF-16LE").trim();
+			} catch (UnsupportedEncodingException e) {
+				e.printStackTrace();
+			}
+
+			return description;
+		}
+		public boolean isCritical() {
+			return false;
+		}
+	},
+	
+	FLASH_PIX_VERSION("FlashPixVersion", (short)0xa000) {
+		public String getFieldAsString(Object value) {
+			return new String((byte[])value).trim();
+		}
+	},
+	COLOR_SPACE("ColorSpace", (short)0xa001) {
+		public String getFieldAsString(Object value) {
+			//
+			int intValue = ((int[])value)[0];
+			String description = "Warning: unknown color space value: " + intValue;
+			
+			switch(intValue) {
+				case 1:	description = "sRGB"; break;
+				case 65535: description = "Uncalibrated";	break;
+			}
+			
+			return description;
+		}
+	},
+	EXIF_IMAGE_WIDTH("ExifImageWidth", (short)0xa002),
+	EXIF_IMAGE_HEIGHT("ExifImageHeight", (short)0xa003),
+	RELATED_SOUND_FILE("RelatedSoundFile", (short)0xa004),
+	
+	EXIF_INTEROPERABILITY_OFFSET("ExifInteroperabilitySubIFD", (short)0xa005) {
+		public FieldType getFieldType() {
+			return FieldType.LONG;
+		}
+	},
+	
+	FLASH_ENERGY("FlashEnergy", (short)0xa20b),
+	SPATIAL_FREQUENCY_RESPONSE("SpatialFrequencyResponse", (short)0xa20c),
+	FOCAL_PLANE_X_RESOLUTION("FocalPlanXResolution", (short)0xa20e),
+	FOCAL_PLANE_Y_RESOLUTION("FocalPlanYResolution", (short)0xa20f),
+	FOCAL_PLANE_RESOLUTION_UNIT("FocalPlanResolutionUnit", (short)0xa210),
+	
+	SUBJECT_LOCATION("SubjectLocation", (short)0xa214),
+	EXPOSURE_INDEX("ExposureIndex", (short)0xa215),
+	SENSING_METHOD("SensingMethod", (short)0xa217),
+	
+	FILE_SOURCE("FileSource", (short)0xa300),
+	SCENE_TYPE("SceneType", (short)0xa301),
+	CFA_PATTERN("CFAPattern", (short)0xa302),
+	
+	CUSTOM_RENDERED("CustomRendered", (short)0xa401),
+	EXPOSURE_MODE("ExposureMode", (short)0xa402),
+	WHITE_BALENCE("WhileBalence", (short)0xa403),
+	DIGITAL_ZOOM_RATIO("DigitalZoomRatio", (short)0xa404),
+	FOCAL_LENGTH_IN_35MM_FORMAT("FocalLengthIn35mmFormat", (short)0xa405),
+	SCENE_CAPTURE_TYPE("SceneCaptureType", (short)0xa406),
+	GAIN_CONTROL("GainControl", (short)0xa407),
+	CONTRAST("Contrast", (short)0xa408),
+	SATURATION("Saturation", (short)0xa409),
+	SHARPNESS("Sharpness", (short)0xa40a),
+	DEVICE_SETTING_DESCRIPTION("DeviceSettingDescription", (short)0xa40b),
+	SUBJECT_DISTANCE_RANGE("SubjectDistanceRange", (short)0xa40c),
+	
+	IMAGE_UNIQUE_ID("ImageUniqueID", (short)0xa420),
+	
+	OWNER_NAME("OwnerName", (short)0xa430),
+	BODY_SERIAL_NUMBER("BodySerialNumber", (short)0xa431),
+	LENS_SPECIFICATION("LensSpecification", (short)0xa432),
+	LENS_Make("LensMake", (short)0xa433),
+	LENS_MODEL("LensModel", (short)0xa434),
+	LENS_SERIAL_NUMBER("LensSerialNumber", (short)0xa435),
+	
+	EXPAND_SOFTWARE("ExpandSoftware", (short)0xafc0),
+	EXPAND_LENS("ExpandLens", (short)0xafc1),
+	EXPAND_FILM("ExpandFilm", (short)0xafc2),
+	EXPAND_FILTER_LENS("ExpandFilterLens", (short)0xafc3),
+	EXPAND_SCANNER("ExpandScanner", (short)0xafc4),
+	EXPAND_FLASH_LAMP("ExpandFlashLamp", (short)0xafc5),
+		
+	PADDING("Padding", (short)0xea1c),
+	
+	UNKNOWN("Unknown",  (short)0xffff); 
+	
+	private ExifTag(String name, short value)
+	{
+		this.name = name;
+		this.value = value;
+	} 
+	
+	public String getName()
+	{
+		return this.name;
+	}	
+	
+	public short getValue()
+	{
+		return this.value;
+	}
+	
+	@Override
+    public String toString() {
+		if (this == UNKNOWN)
+			return name;
+		return name + " [Value: " + StringUtils.shortToHexStringMM(value) +"]";
+	}
+	
+    public static Tag fromShort(short value) {
+       	ExifTag exifTag = tagMap.get(value);
+    	if (exifTag == null)
+    	   return TiffTag.UNKNOWN;
+   		return exifTag;
+    }
+    
+    private static final Map<Short, ExifTag> tagMap = new HashMap<Short, ExifTag>();
+       
+    static
+    {
+      for(ExifTag exifTag : values()) {
+           tagMap.put(exifTag.getValue(), exifTag);
+      }
+    } 
+	
+	/**
+     * Intended to be overridden by certain tags to provide meaningful string
+     * representation of the field value such as compression, photo metric interpretation etc.
+     * 
+	 * @param value field value to be mapped to a string
+	 * @return a string representation of the field value or empty string if no meaningful string
+	 * 	representation exists.
+	 */
+    public String getFieldAsString(Object value) {
+    	return "";
+	}
+    
+    public boolean isCritical() {
+    	return true;
+    }
+	
+	public FieldType getFieldType() {
+		return FieldType.UNKNOWN;
+	}
+	
+	private final String name;
+	private final short value;	
+}
diff --git a/src/pixy/meta/exif/ExifThumbnail.java b/src/pixy/meta/exif/ExifThumbnail.java
new file mode 100644
index 0000000..ab97d26
--- /dev/null
+++ b/src/pixy/meta/exif/ExifThumbnail.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * ExifThumbnail.java
+ *
+ * Who   Date         Description
+ * ====  ==========   ===============================================
+ * WY    27Apr2015    Added copy constructor
+ * WY    10Apr2015    Added new constructor, changed write()
+ * WY    13Mar2015    initial creation
+ */
+
+package pixy.meta.exif;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+import android.graphics.Bitmap;
+import pixy.meta.Thumbnail;
+import pixy.meta.tiff.TIFFMeta;
+import pixy.image.tiff.IFD;
+import pixy.image.tiff.LongField;
+import pixy.image.tiff.RationalField;
+import pixy.image.tiff.ShortField;
+import pixy.image.tiff.TiffField;
+import pixy.image.tiff.TiffFieldEnum;
+import pixy.image.tiff.TiffTag;
+import pixy.io.FileCacheRandomAccessInputStream;
+import pixy.io.MemoryCacheRandomAccessOutputStream;
+import pixy.io.RandomAccessInputStream;
+import pixy.io.RandomAccessOutputStream;
+
+/**
+ * Encapsulates image EXIF thumbnail metadata
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2015
+ */
+public class ExifThumbnail extends Thumbnail {
+	// Comprised of an IFD and an associated image
+	// Create thumbnail IFD (IFD1 in the case of JPEG EXIF segment)
+	private IFD thumbnailIFD = new IFD();
+		
+	public ExifThumbnail() { }
+	
+	public ExifThumbnail(Bitmap thumbnail) {
+		super(thumbnail);
+	}
+	
+	public ExifThumbnail(ExifThumbnail other) { // Copy constructor
+		this.dataType = other.dataType;
+		this.height = other.height;
+		this.width = other.width;
+		this.thumbnail = other.thumbnail;
+		this.compressedThumbnail = other.compressedThumbnail;
+		this.thumbnailIFD = other.thumbnailIFD;
+	}
+	
+	public ExifThumbnail(int width, int height, int dataType, byte[] compressedThumbnail) {
+		super(width, height, dataType, compressedThumbnail);
+	}
+	
+	public ExifThumbnail(int width, int height, int dataType, byte[] compressedThumbnail, IFD thumbnailIFD) {
+		super(width, height, dataType, compressedThumbnail);
+		this.thumbnailIFD = thumbnailIFD;
+	}
+	
+	public void write(OutputStream os) throws IOException {
+		RandomAccessOutputStream randOS = null;
+		if(os instanceof RandomAccessOutputStream) randOS = (RandomAccessOutputStream)os;
+		else randOS = new MemoryCacheRandomAccessOutputStream(os);
+		int offset = (int)randOS.getStreamPointer(); // Get current write position
+		if(getDataType() == Thumbnail.DATA_TYPE_KJpegRGB) { // Compressed old-style JPEG format
+			byte[] compressedImage = getCompressedImage();
+			if(compressedImage == null) throw new IllegalArgumentException("Expected compressed thumbnail data does not exist!");
+			thumbnailIFD.addField(new LongField(TiffTag.JPEG_INTERCHANGE_FORMAT.getValue(), new int[] {0})); // Placeholder
+			thumbnailIFD.addField(new LongField(TiffTag.JPEG_INTERCHANGE_FORMAT_LENGTH.getValue(), new int[] {compressedImage.length}));
+			offset = thumbnailIFD.write(randOS, offset);
+			// This line is very important!!!
+			randOS.seek(offset);
+			randOS.write(getCompressedImage());
+			// Update fields
+			randOS.seek(thumbnailIFD.getField(TiffTag.JPEG_INTERCHANGE_FORMAT).getDataOffset());
+			randOS.writeInt(offset);
+		} else if(getDataType() == Thumbnail.DATA_TYPE_TIFF) { // Uncompressed TIFF format
+			// Read the IFDs into a list first
+			List<IFD> list = new ArrayList<IFD>();			   
+			RandomAccessInputStream tiffIn = new FileCacheRandomAccessInputStream(new ByteArrayInputStream(getCompressedImage()));
+			TIFFMeta.readIFDs(list, tiffIn);
+			TiffField<?> stripOffset = list.get(0).getField(TiffTag.STRIP_OFFSETS);
+    		if(stripOffset == null) 
+    			stripOffset = list.get(0).getField(TiffTag.TILE_OFFSETS);
+    		TiffField<?> stripByteCounts = list.get(0).getField(TiffTag.STRIP_BYTE_COUNTS);
+    		if(stripByteCounts == null) 
+    			stripByteCounts = list.get(0).getField(TiffTag.TILE_BYTE_COUNTS);
+    		offset = list.get(0).write(randOS, offset); // Write out the thumbnail IFD
+    		int[] off = new int[0];;
+    		if(stripOffset != null) { // Write out image data and update offset array
+    			off = stripOffset.getDataAsLong();
+    			int[] counts = stripByteCounts.getDataAsLong();
+    			for(int i = 0; i < off.length; i++) {
+    				tiffIn.seek(off[i]);
+    				byte[] temp = new byte[counts[i]];
+    				tiffIn.readFully(temp);
+    				randOS.seek(offset);
+    				randOS.write(temp);
+    				off[i] = offset;
+    				offset += counts[i];    				
+    			}
+    		}
+    		tiffIn.shallowClose();
+    		// Update offset field
+			randOS.seek(stripOffset.getDataOffset());
+			for(int i : off)
+				randOS.writeInt(i);		
+		} else {
+			Bitmap thumbnail = getRawImage();
+			if(thumbnail == null) throw new IllegalArgumentException("Expected raw data thumbnail does not exist!");
+			// We are going to write the IFD and associated thumbnail
+			int thumbnailWidth = thumbnail.getWidth();
+			int thumbnailHeight = thumbnail.getHeight();
+			thumbnailIFD.addField(new ShortField(TiffTag.IMAGE_WIDTH.getValue(), new short[]{(short)thumbnailWidth}));
+			thumbnailIFD.addField(new ShortField(TiffTag.IMAGE_LENGTH.getValue(), new short[]{(short)thumbnailHeight}));
+			thumbnailIFD.addField(new LongField(TiffTag.JPEG_INTERCHANGE_FORMAT.getValue(), new int[]{0})); // Place holder
+			thumbnailIFD.addField(new LongField(TiffTag.JPEG_INTERCHANGE_FORMAT_LENGTH.getValue(), new int[]{0})); // Place holder
+			// Other related tags
+			thumbnailIFD.addField(new RationalField(TiffTag.X_RESOLUTION.getValue(), new int[] {thumbnailWidth, 1}));
+			thumbnailIFD.addField(new RationalField(TiffTag.Y_RESOLUTION.getValue(), new int[] {thumbnailHeight, 1}));
+			thumbnailIFD.addField(new ShortField(TiffTag.RESOLUTION_UNIT.getValue(), new short[]{1})); //No absolute unit of measurement
+			thumbnailIFD.addField(new ShortField(TiffTag.PHOTOMETRIC_INTERPRETATION.getValue(), new short[]{(short)TiffFieldEnum.PhotoMetric.YCbCr.getValue()}));
+			thumbnailIFD.addField(new ShortField(TiffTag.SAMPLES_PER_PIXEL.getValue(), new short[]{3}));		
+			thumbnailIFD.addField(new ShortField(TiffTag.BITS_PER_SAMPLE.getValue(), new short[]{8, 8, 8}));
+			thumbnailIFD.addField(new ShortField(TiffTag.YCbCr_SUB_SAMPLING.getValue(), new short[]{1, 1}));
+			thumbnailIFD.addField(new ShortField(TiffTag.PLANAR_CONFIGURATTION.getValue(), new short[]{(short)TiffFieldEnum.PlanarConfiguration.CONTIGUOUS.getValue()}));
+			thumbnailIFD.addField(new ShortField(TiffTag.COMPRESSION.getValue(), new short[]{(short)TiffFieldEnum.Compression.OLD_JPG.getValue()}));
+			thumbnailIFD.addField(new ShortField(TiffTag.ROWS_PER_STRIP.getValue(), new short[]{(short)thumbnailHeight}));
+			// Write the thumbnail IFD
+			// This line is very important!!!
+			randOS.seek(thumbnailIFD.write(randOS, offset));
+			// This is amazing. We can actually keep track of how many bytes have been written to
+			// the underlying stream
+			long startOffset = randOS.getStreamPointer();
+			try {
+				thumbnail.compress(Bitmap.CompressFormat.JPEG, writeQuality, randOS);
+			} catch (Exception e) {
+				throw new RuntimeException("Unable to compress thumbnail as JPEG");
+			}
+			long finishOffset = randOS.getStreamPointer();			
+			int totalOut = (int)(finishOffset - startOffset);
+			// Update fields
+			randOS.seek(thumbnailIFD.getField(TiffTag.JPEG_INTERCHANGE_FORMAT).getDataOffset());
+			randOS.writeInt((int)startOffset);
+			randOS.seek(thumbnailIFD.getField(TiffTag.JPEG_INTERCHANGE_FORMAT_LENGTH).getDataOffset());
+			randOS.writeInt(totalOut);
+		}
+		// Close the RandomAccessOutputStream instance if we created it locally
+		if(!(os instanceof RandomAccessOutputStream)) randOS.shallowClose();
+	}
+}
diff --git a/src/pixy/meta/exif/GPSTag.java b/src/pixy/meta/exif/GPSTag.java
new file mode 100644
index 0000000..e86e6d8
--- /dev/null
+++ b/src/pixy/meta/exif/GPSTag.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.exif;
+
+import java.text.DecimalFormat;
+import java.util.HashMap;
+import java.util.Map;
+
+import pixy.meta.exif.GPSTag;
+import pixy.image.tiff.FieldType;
+import pixy.image.tiff.Tag;
+import pixy.image.tiff.TiffTag;
+import pixy.string.StringUtils;
+
+/**
+ * Defines GPS tags
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2015
+ */
+public enum GPSTag implements Tag {
+	// EXIF GPSSubIFD tags
+	GPS_VERSION_ID("GPSVersionID", (short)0x0000),
+	GPS_LATITUDE_REF("GPSLatitudeRef", (short)0x0001),
+	GPS_LATITUDE("GPSLatitude", (short)0x0002) {
+		public String getFieldAsString(Object value) {
+			int[] intValues = (int[])value;
+			if(intValues.length != 6)
+				throw new IllegalArgumentException("Wrong number of GPSLatitute data number: " + intValues.length);
+			//formatting numbers up to 3 decimal places in Java
+	        DecimalFormat df = new DecimalFormat("#,###,###.###");
+	        return StringUtils.rationalToString(df,  true, intValues[0], intValues[1]) + '\u00B0' + StringUtils.rationalToString(df, true, intValues[2], intValues[3])
+	        		+ "'" + StringUtils.rationalToString(df, true, intValues[4], intValues[5]) + "\"";
+		}
+	},
+	GPS_LONGITUDE_REF("GPSLongitudeRef", (short)0x0003),
+	GPS_LONGITUDE("GPSLongitude", (short)0x0004) {
+		public String getFieldAsString(Object value) {
+			int[] intValues = (int[])value;
+			if(intValues.length != 6)
+				throw new IllegalArgumentException("Wrong number of GPSLongitude data number: " + intValues.length);
+			//formatting numbers up to 3 decimal places in Java
+	        DecimalFormat df = new DecimalFormat("#,###,###.###");
+	        return StringUtils.rationalToString(df,  true, intValues[0], intValues[1]) + '\u00B0' + StringUtils.rationalToString(df, true, intValues[2], intValues[3])
+	        		+ "'" + StringUtils.rationalToString(df, true, intValues[4], intValues[5]) + "\"";
+		}
+	},
+	GPS_ALTITUDE_REF("GPSAltitudeRef", (short)0x0005),
+	GPS_ALTITUDE("GPSAltitude", (short)0x0006) {
+		public String getFieldAsString(Object value) {
+			int[] intValues = (int[])value;
+			if(intValues.length != 2)
+				throw new IllegalArgumentException("Wrong number of GPSAltitute data number: " + intValues.length);
+			//formatting numbers up to 3 decimal places in Java
+	        DecimalFormat df = new DecimalFormat("#,###,###.###");
+	        return StringUtils.rationalToString(df, true, intValues) + "m";	
+		}
+	},
+	GPS_TIME_STAMP("GPSTimeStamp", (short)0x0007) {
+		public String getFieldAsString(Object value) {
+			int[] intValues = (int[])value;
+			if(intValues.length != 6)
+				throw new IllegalArgumentException("Wrong number of GPSTimeStamp data number: " + intValues.length);
+			//formatting numbers up to 2 decimal places in Java
+	        DecimalFormat df = new DecimalFormat("#,###,###.##");
+	        return StringUtils.rationalToString(df, true, intValues[0], intValues[1]) + ":" + StringUtils.rationalToString(df, true, intValues[2], intValues[3])
+	        		+ ":" + StringUtils.rationalToString(df, true, intValues[4], intValues[5]);	
+		}
+	},
+	GPS_SATELLITES("GPSSatellites", (short)0x0008),
+	GPS_STATUS("GPSStatus", (short)0x0009),
+	GPS_MEASURE_MODE("GPSMeasureMode", (short)0x000a),	
+	GPS_DOP("GPSDOP/ProcessingSoftware", (short)0x000b),
+	GPS_SPEED_REF("GPSSpeedRef", (short)0x000c),
+	GPSSpeed("GPSSpeed", (short)0x000d),
+	GPS_TRACK_REF("GPSTrackRef", (short)0x000e),
+	GPS_TRACK("GPSTrack", (short)0x000f),
+	GPS_IMG_DIRECTION_REF("GPSImgDirectionRef", (short)0x0010),
+	GPS_IMG_DIRECTION("GPSImgDirection", (short)0x0011) {
+		public String getFieldAsString(Object value) {
+			int[] intValues = (int[])value;
+			if(intValues.length != 2)
+				throw new IllegalArgumentException("Wrong number of GPSImgDirection data number: " + intValues.length);
+			//formatting numbers up to 3 decimal places in Java
+	        DecimalFormat df = new DecimalFormat("#,###,###.###");
+	        return StringUtils.rationalToString(df, true, intValues) + '\u00B0';	
+		}
+	},
+	GPS_MAP_DATUM("GPSMapDatum", (short)0x0012),
+	GPS_DEST_LATITUDE_REF("GPSDestLatitudeRef", (short)0x0013),
+	GPS_DEST_LATITUDE("GPSDestLatitude", (short)0x0014) {
+		public String getFieldAsString(Object value) {
+			int[] intValues = (int[])value;
+			if(intValues.length != 6)
+				throw new IllegalArgumentException("Wrong number of GPSDestLatitute data number: " + intValues.length);
+			//formatting numbers up to 3 decimal places in Java
+	        DecimalFormat df = new DecimalFormat("#,###,###.###");
+	        return StringUtils.rationalToString(df,  true, intValues[0], intValues[1]) + '\u00B0' + StringUtils.rationalToString(df, true, intValues[2], intValues[3])
+	        		+ "'" + StringUtils.rationalToString(df, true, intValues[4], intValues[5]) + "\"";
+		}
+	},
+	GPS_DEST_LONGITUDE_REF("GPSDestLongitudeRef", (short)0x0015),
+	GPS_DEST_LONGITUDE("GPSDestLongitude", (short)0x0016) {
+		public String getFieldAsString(Object value) {
+			int[] intValues = (int[])value;
+			if(intValues.length != 6)
+				throw new IllegalArgumentException("Wrong number of GPSDestLongitude data number: " + intValues.length);
+			//formatting numbers up to 3 decimal places in Java
+	        DecimalFormat df = new DecimalFormat("#,###,###.###");
+	        return StringUtils.rationalToString(df,  true, intValues[0], intValues[1]) + '\u00B0' + StringUtils.rationalToString(df, true, intValues[2], intValues[3])
+	        		+ "'" + StringUtils.rationalToString(df, true, intValues[4], intValues[5]) + "\"";
+		}
+	},
+	GPS_DEST_BEARING_REF("GPSDestBearingRef", (short)0x0017),
+	GPS_DEST_BEARING("GPSDestBearing", (short)0x0018) {
+		public String getFieldAsString(Object value) {
+			int[] intValues = (int[])value;
+			if(intValues.length != 2)
+				throw new IllegalArgumentException("Wrong number of GPSDestBearing data number: " + intValues.length);
+			//formatting numbers up to 3 decimal places in Java
+	        DecimalFormat df = new DecimalFormat("#,###,###.###");
+	        return StringUtils.rationalToString(df, true, intValues) + "m";	
+		}
+	},
+	GPS_DEST_DISTANCE_REF("GPSDestDistanceRef", (short)0x0019),
+	GPS_DEST_DISTANCE("GPSDestDistance", (short)0x001a) {
+		public String getFieldAsString(Object value) {
+			int[] intValues = (int[])value;
+			if(intValues.length != 2)
+				throw new IllegalArgumentException("Wrong number of GPSDestDistance data number: " + intValues.length);
+			//formatting numbers up to 3 decimal places in Java
+	        DecimalFormat df = new DecimalFormat("#,###,###.###");
+	        return StringUtils.rationalToString(df, true, intValues) + "m";	
+		}
+	},
+	GPS_PROCESSING_METHOD("GPSProcessingMethod", (short)0x001b),
+	GPS_AREA_INFORMATION("GPSAreaInformation", (short)0x001c),
+	GPS_DATE_STAMP("GPSDateStamp", (short)0x001d),
+	GPS_DIFFERENTIAL("GPSDifferential", (short)0x001e),
+	GPS_HPOSITIONING_ERROR("GPSHPositioningError", (short)0x001f),
+	// unknown tag
+	UNKNOWN("Unknown",  (short)0xffff); 
+    // End of EXIF GPSSubIFD tags
+	
+	private GPSTag(String name, short value)
+	{
+		this.name = name;
+		this.value = value;
+	}
+	
+	public String getName() {
+		return name;
+	}
+	
+	public short getValue() {
+		return value;
+	}
+	
+	@Override
+    public String toString() {
+		if (this == UNKNOWN)
+			return name;
+		return name + " [Value: " + StringUtils.shortToHexStringMM(value) +"]";
+	}
+	
+    public static Tag fromShort(short value) {
+       	GPSTag tag = tagMap.get(value);
+    	if (tag == null)
+    	   return TiffTag.UNKNOWN;
+   		return tag;
+    }
+    
+    private static final Map<Short, GPSTag> tagMap = new HashMap<Short, GPSTag>();
+       
+    static
+    {
+      for(GPSTag tag : values()) {
+           tagMap.put(tag.getValue(), tag);
+      }
+    }
+    
+    /**
+     * Intended to be overridden by certain tags to provide meaningful string
+     * representation of the field value such as compression, photo metric interpretation etc.
+     * 
+	 * @param value field value to be mapped to a string
+	 * @return a string representation of the field value or empty string if no meaningful string
+	 * 	representation exists.
+	 */
+	public String getFieldAsString(Object value) {
+    	return "";
+	}
+	
+	public boolean isCritical() {
+    	return true;
+    }
+	
+	public FieldType getFieldType() {
+		return FieldType.UNKNOWN;
+	}
+	
+	private final String name;
+	private final short value;
+}
diff --git a/src/pixy/meta/exif/InteropTag.java b/src/pixy/meta/exif/InteropTag.java
new file mode 100644
index 0000000..cbf09fa
--- /dev/null
+++ b/src/pixy/meta/exif/InteropTag.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.exif;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import pixy.meta.exif.InteropTag;
+import pixy.image.tiff.FieldType;
+import pixy.image.tiff.Tag;
+import pixy.image.tiff.TiffTag;
+import pixy.string.StringUtils;
+
+/**
+ * Defines Interoperability tags
+ *  
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2015
+ */
+public enum InteropTag implements Tag {
+	// EXIF InteropSubIFD tags
+	INTEROPERABILITY_INDEX("InteroperabilityIndex", (short)0x0001) {
+		public FieldType getFieldType() {
+			return FieldType.ASCII;
+		}
+	},
+	INTEROPERABILITY_VERSION("InteroperabilityVersion", (short)0x0002) {
+		public FieldType getFieldType() {
+			return FieldType.UNDEFINED;
+		}
+	},
+	RELATED_IMAGE_FILE_FORMAT("RelatedImageFileFormat", (short)0x1000) {
+		public FieldType getFieldType() {
+			return FieldType.ASCII;
+		}
+	},
+	RELATED_IMAGE_WIDTH("RelatedImageWidth", (short)0x1001) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	RELATED_IMAGE_LENGTH("RelatedImageLength", (short)0x1002) {
+		public FieldType getFieldType() {
+			return FieldType.SHORT;
+		}
+	},
+	// unknown tag
+	UNKNOWN("Unknown",  (short)0xffff); 
+	// End of IneropSubIFD tags
+		
+	private InteropTag(String name, short value)
+	{
+		this.name = name;
+		this.value = value;
+	}
+	
+	public String getName() {
+		return name;
+	}
+	
+	public short getValue() {
+		return value;
+	}
+	
+	@Override
+    public String toString() {
+		if (this == UNKNOWN)
+			return name;
+		return name + " [Value: " + StringUtils.shortToHexStringMM(value) +"]";
+	}
+	
+    public static Tag fromShort(short value) {
+       	InteropTag tag = tagMap.get(value);
+    	if (tag == null)
+    	   return TiffTag.UNKNOWN;
+   		return tag;
+    }
+    
+    private static final Map<Short, InteropTag> tagMap = new HashMap<Short, InteropTag>();
+       
+    static
+    {
+      for(InteropTag tag : values()) {
+           tagMap.put(tag.getValue(), tag);
+      }
+    }
+    
+    /**
+     * Intended to be overridden by certain tags to provide meaningful string
+     * representation of the field value such as compression, photo metric interpretation etc.
+     * 
+	 * @param value field value to be mapped to a string
+	 * @return a string representation of the field value or empty string if no meaningful string
+	 * 	representation exists.
+	 */
+    public String getFieldAsString(Object value) {
+    	return "";
+	}
+    
+    public boolean isCritical() {
+    	return true;
+    }
+	
+	public FieldType getFieldType() {
+		return FieldType.UNKNOWN;
+	}
+	
+	private final String name;
+	private final short value;
+}
diff --git a/src/pixy/meta/gif/GIFMeta.java b/src/pixy/meta/gif/GIFMeta.java
new file mode 100644
index 0000000..ed8d7a6
--- /dev/null
+++ b/src/pixy/meta/gif/GIFMeta.java
@@ -0,0 +1,365 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * GIFMetq.java
+ *
+ * Who   Date       Description
+ * ====  =========  ==================================================
+ * WY    07Apr2016  Rewrite insertXMPApplicationBlock() to leverage GifXMP
+ * WY    16Sep2015  Added insertComment() to insert comment block
+ * WY    06Jul2015  Added insertXMP(InputSream, OutputStream, XMP)
+ * WY    30Mar2015  Fixed bug with insertXMP() replacing '\0' with ' '
+ * WY    13Mar2015  Initial creation
+ */
+
+package pixy.meta.gif;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.w3c.dom.Document;
+
+import pixy.meta.Metadata;
+import pixy.meta.MetadataType;
+import pixy.meta.image.Comments;
+import pixy.meta.xmp.XMP;
+import pixy.io.IOUtils;
+import pixy.string.XMLUtils;
+import pixy.util.ArrayUtils;
+
+/**
+ * GIF Metadata tool
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 04/16/2014
+ */
+public class GIFMeta {
+	// Define constants
+	public static final byte IMAGE_SEPARATOR = 0x2c; // ","
+	public static final byte IMAGE_TRAILER = 0x3b; // ";"
+	public static final byte EXTENSION_INTRODUCER = 0x21; // "!"
+	public static final byte GRAPHIC_CONTROL_LABEL = (byte)0xf9;
+	public static final byte APPLICATION_EXTENSION_LABEL = (byte)0xff;
+	public static final byte COMMENT_EXTENSION_LABEL = (byte)0xfe;
+	public static final byte TEXT_EXTENSION_LABEL = 0x01;
+	
+	public static final int DISPOSAL_UNSPECIFIED = 0;		
+	public static final int DISPOSAL_LEAVE_AS_IS = 1;
+	public static final int DISPOSAL_RESTORE_TO_BACKGROUND = 2;
+	public static final int DISPOSAL_RESTORE_TO_PREVIOUS = 3;
+	
+	// Data transfer object for multiple thread support
+	private static class DataTransferObject {
+		private byte[] header;	
+		private byte[] logicalScreenDescriptor;
+		private byte[] globalPalette;
+		private byte[] imageDescriptor;
+		private Map<MetadataType, Metadata> metadataMap;
+		private Comments comments;
+	}
+	
+	public static void insertComments(InputStream is, OutputStream os, List<String> comments) throws IOException {
+		// Read and copy header and LSD
+ 		// Create a new data transfer object to hold data
+ 		DataTransferObject DTO = new DataTransferObject();
+ 		readHeader(is, DTO);
+ 		readLSD(is, DTO);
+ 		os.write(DTO.header);
+ 		os.write(DTO.logicalScreenDescriptor);
+		if((DTO.logicalScreenDescriptor[4]&0x80) == 0x80) {
+			int bitsPerPixel = (DTO.logicalScreenDescriptor[4]&0x07)+1;
+			int colorsUsed = (1 << bitsPerPixel);
+			
+			readGlobalPalette(is, colorsUsed, DTO);
+			os.write(DTO.globalPalette);
+		}		 		
+		int numOfComments = comments.size();
+		for(int i = 0; i < numOfComments; i++) {
+			os.write(EXTENSION_INTRODUCER);
+			os.write(COMMENT_EXTENSION_LABEL);
+			byte[] commentBytes = comments.get(i).getBytes();
+			int numBlocks = commentBytes.length/0xff;
+			int leftOver = commentBytes.length % 0xff;
+			int offset = 0;
+			if(numBlocks > 0) {
+				for(int block = 0; block < numBlocks; block++) {
+					os.write(0xff);
+					os.write(commentBytes, offset, 0xff);
+					offset += 0xff;
+				}
+			}
+			if(leftOver > 0) {
+				os.write(leftOver);
+				os.write(commentBytes, offset, leftOver);
+			}
+			os.write(0);			
+		}
+		// Copy the rest of the input stream
+ 		byte buf[] = new byte[10240]; // 10K
+ 		int bytesRead = is.read(buf);
+ 		
+ 		while(bytesRead != -1) {
+ 			os.write(buf, 0, bytesRead);
+ 			bytesRead = is.read(buf);
+ 		}
+	}
+	
+	public static void insertXMPApplicationBlock(InputStream is, OutputStream os, XMP xmp) throws IOException {
+		insertXMPApplicationBlock(is, os, xmp.getData());
+	}
+	
+	public static void insertXMPApplicationBlock(InputStream is, OutputStream os, byte[] xmp) throws IOException {
+    	byte[] buf = new byte[14];
+ 		buf[0] = EXTENSION_INTRODUCER; // Extension introducer
+ 		buf[1] = APPLICATION_EXTENSION_LABEL; // Application extension label
+ 		buf[2] = 0x0b; // Block size
+ 		buf[3] = 'X'; // Application Identifier (8 bytes)
+ 		buf[4] = 'M';
+ 		buf[5] = 'P';
+ 		buf[6] = ' ';
+ 		buf[7] = 'D';
+ 		buf[8] = 'a';
+ 		buf[9] = 't';
+ 		buf[10]= 'a';
+ 		buf[11]= 'X';// Application Authentication Code (3 bytes)
+ 		buf[12]= 'M';
+ 		buf[13]= 'P'; 		
+ 		// Create a byte array from 0x01, 0xFF - 0x00, 0x00
+ 		byte[] magic_trailer = new byte[258];
+ 		
+ 		magic_trailer[0] = 0x01;
+ 		
+ 		for(int i = 255; i >= 0; i--)
+ 			magic_trailer[256 - i] = (byte)i;
+ 	
+ 		// Read and copy header and LSD
+ 		// Create a new data transfer object to hold data
+ 		DataTransferObject DTO = new DataTransferObject();
+ 		readHeader(is, DTO);
+ 		readLSD(is, DTO);
+ 		os.write(DTO.header);
+ 		os.write(DTO.logicalScreenDescriptor);
+
+		if((DTO.logicalScreenDescriptor[4]&0x80) == 0x80) {
+			int bitsPerPixel = (DTO.logicalScreenDescriptor[4]&0x07)+1;
+			int colorsUsed = (1 << bitsPerPixel);
+			
+			readGlobalPalette(is, colorsUsed, DTO);
+			os.write(DTO.globalPalette);
+		}
+ 		
+ 		// Insert XMP here
+ 		// Write extension introducer and application identifier
+ 		os.write(buf);
+ 		// Write the XMP packet
+ 		os.write(xmp);
+ 		// Write the magic trailer
+ 		os.write(magic_trailer);
+ 		// End of XMP data 		
+ 		// Copy the rest of the input stream
+ 		buf = new byte[10240]; // 10K
+ 		int bytesRead = is.read(buf);
+ 		
+ 		while(bytesRead != -1) {
+ 			os.write(buf, 0, bytesRead);
+ 			bytesRead = is.read(buf);
+ 		}
+    }
+	
+	public static void insertXMPApplicationBlock(InputStream is, OutputStream os, String xmp) throws IOException {
+		Document doc = XMLUtils.createXML(xmp);
+		XMLUtils.insertLeadingPI(doc, "xpacket", "begin='' id='W5M0MpCehiHzreSzNTczkc9d'");
+		XMLUtils.insertTrailingPI(doc, "xpacket", "end='w'");
+		// Serialize doc to byte array
+		byte[] xmpBytes = XMLUtils.serializeToByteArray(doc);
+		insertXMPApplicationBlock(is, os, xmpBytes);
+	}
+	
+	private static boolean readFrame(InputStream is, DataTransferObject DTO) throws IOException {
+		// Need to reset some of the fields
+		int disposalMethod = -1;
+		// End of fields reset
+	   
+		int image_separator = 0;
+	
+		do {		   
+			image_separator = is.read();
+			    
+			if(image_separator == -1 || image_separator == 0x3b) { // End of stream 
+				return false;
+			}
+			    
+			if (image_separator == 0x21) { // (!) Extension Block
+				int func = is.read();
+				int len = is.read();
+				
+				if (func == 0xf9) {
+					// Graphic Control Label - identifies the current block as a Graphic Control Extension
+					//<<Start of graphic control block>>
+					int packedFields = is.read();
+					// Determine the disposal method
+					disposalMethod = ((packedFields&0x1c)>>2);
+					switch(disposalMethod) {
+						case DISPOSAL_UNSPECIFIED:
+							// Frame disposal method: UNSPECIFIED
+						case DISPOSAL_LEAVE_AS_IS:
+							// Frame disposal method: LEAVE_AS_IS
+						case DISPOSAL_RESTORE_TO_BACKGROUND:
+							// Frame disposal method: RESTORE_TO_BACKGROUND
+						case DISPOSAL_RESTORE_TO_PREVIOUS:
+							// Frame disposal method: RESTORE_TO_PREVIOUS
+							break;
+						default:
+							//throw new RuntimeException("Invalid GIF frame disposal method: " + disposalMethod);
+					}
+					// Check for transparent color flag
+					if((packedFields&0x01) == 0x01) {
+						IOUtils.skipFully(is, 2);
+						// Transparent GIF
+						is.read(); // Transparent color index
+						len = is.read();// len=0, block terminator!
+					} else {
+						IOUtils.skipFully(is, 3);
+						len = is.read();// len=0, block terminator!
+					}
+					// <<End of graphic control block>>
+				} else if(func == 0xff) { // Application block
+					// Application block
+					byte[] xmp_id = {'X', 'M', 'P', ' ', 'D', 'a', 't', 'a', 'X', 'M', 'P' };
+					byte[] temp = new byte[0x0B];
+					IOUtils.readFully(is, temp);
+					// If we have XMP data
+					if(Arrays.equals(xmp_id, temp)) {
+						ByteArrayOutputStream bout = new ByteArrayOutputStream();
+						len = is.read();
+						while(len != 0) {
+							bout.write(len);
+							temp = new byte[len];
+							IOUtils.readFully(is, temp);
+							bout.write(temp);
+							len = is.read();
+						}
+						byte[] xmp = bout.toByteArray();
+						// Remove the magic trailer - 258 bytes minus the block terminator
+						len = xmp.length - 257;
+						if(len > 0) // Put it into the Meta data map
+							DTO.metadataMap.put(MetadataType.XMP, new GifXMP(ArrayUtils.subArray(xmp, 0, len)));
+						len = 0; // We're already at block terminator
+					} else 
+						len = is.read(); // Block terminator					
+				} else if(func == 0xfe) { // Comment block
+					// Comment block
+					byte[] comment = new byte[len];
+					IOUtils.readFully(is, comment);
+					if(DTO.comments == null) DTO.comments = new Comments();
+					DTO.comments.addComment(comment);
+					// Comment: new String(comment)
+					len = is.read();
+				}
+				// GIF87a specification mentions the repetition of multiple length
+				// blocks while GIF89a gives no specific description. For safety, here
+				// a while loop is used to check for block terminator!
+				while(len != 0) {
+					IOUtils.skipFully(is, len);
+					len = is.read();// len=0, block terminator!
+				} 
+			}
+		} while(image_separator != 0x2c); // ","
+		
+		// <<Start of new frame>>		
+		readImageDescriptor(is, DTO);
+		
+		int colorsUsed = 1 << ((DTO.logicalScreenDescriptor[4]&0x07)+1);
+		
+		byte[] localPalette = null;
+		
+		if((DTO.imageDescriptor[8]&0x80) == 0x80) {
+			// A local color map is present
+			int bitsPerPixel = (DTO.imageDescriptor[8]&0x07)+1;
+			// Colors used in local palette
+			colorsUsed = (1<<bitsPerPixel);
+			localPalette = new byte[3*colorsUsed];
+		    is.read(localPalette);
+		}		
+	
+		if(localPalette == null) localPalette = DTO.globalPalette;	
+		is.read(); // LZW Minimum Code Size		
+		int len = 0;
+		
+		while((len = is.read()) > 0) {
+			byte[] block = new byte[len];
+			is.read(block);
+		}
+		
+		return true;
+	}
+	
+	private static void readGlobalPalette(InputStream is, int num_of_color, DataTransferObject DTO) throws IOException {
+		 DTO.globalPalette = new byte[num_of_color*3];
+		 is.read(DTO.globalPalette);
+	}
+	
+	private static void readHeader(InputStream is, DataTransferObject DTO) throws IOException {
+		DTO.header = new byte[6]; // GIFXXa
+		is.read(DTO.header);
+	}
+	
+	private static void readImageDescriptor(InputStream is, DataTransferObject DTO) throws IOException {
+		DTO.imageDescriptor = new byte[9];
+	    is.read(DTO.imageDescriptor);
+	}
+	
+	private static void readLSD(InputStream is, DataTransferObject DTO) throws IOException {
+		DTO.logicalScreenDescriptor = new byte[7];
+		is.read(DTO.logicalScreenDescriptor);
+	}
+	
+	public static Map<MetadataType, Metadata> readMetadata(InputStream is) throws IOException {
+		// Create a new data transfer object to hold data
+		DataTransferObject DTO = new DataTransferObject();
+		// Created a Map for the Meta data
+		DTO.metadataMap = new HashMap<MetadataType, Metadata>(); 
+				
+		readHeader(is, DTO);
+		readLSD(is, DTO);
+		
+		// Packed byte
+		if((DTO.logicalScreenDescriptor[4]&0x80) == 0x80) {
+			// A global color map is present 
+			int bitsPerPixel = (DTO.logicalScreenDescriptor[4]&0x07)+1;
+			int colorsUsed = (1 << bitsPerPixel);
+			
+			readGlobalPalette(is, colorsUsed, DTO);			
+		}
+		
+		while(readFrame(is, DTO)) {
+			;	
+		}
+		
+		if(DTO.comments != null)
+			DTO.metadataMap.put(MetadataType.COMMENT, DTO.comments);		
+			
+		return DTO.metadataMap;		
+	}
+	
+	private GIFMeta() {}
+}
diff --git a/src/pixy/meta/gif/GifXMP.java b/src/pixy/meta/gif/GifXMP.java
new file mode 100644
index 0000000..72d21d8
--- /dev/null
+++ b/src/pixy/meta/gif/GifXMP.java
@@ -0,0 +1,61 @@
+package pixy.meta.gif;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import pixy.meta.xmp.XMP;
+import pixy.string.XMLUtils;
+import static pixy.meta.gif.GIFMeta.*;
+
+import org.w3c.dom.Document;
+
+public class GifXMP extends XMP {
+	public GifXMP(byte[] data) {
+		super(data);
+	}
+
+	public GifXMP(String xmp) {
+		super(xmp);
+	}
+	
+	public GifXMP(String xmp, String extendedXmp) {
+		super(xmp, extendedXmp);
+	}
+
+	public void write(OutputStream os) throws IOException {
+		byte[] buf = new byte[14];
+ 		buf[0] = EXTENSION_INTRODUCER; // Extension introducer
+ 		buf[1] = APPLICATION_EXTENSION_LABEL; // Application extension label
+ 		buf[2] = 0x0b; // Block size
+ 		buf[3] = 'X'; // Application Identifier (8 bytes)
+ 		buf[4] = 'M';
+ 		buf[5] = 'P';
+ 		buf[6] = ' ';
+ 		buf[7] = 'D';
+ 		buf[8] = 'a';
+ 		buf[9] = 't';
+ 		buf[10]= 'a';
+ 		buf[11]= 'X';// Application Authentication Code (3 bytes)
+ 		buf[12]= 'M';
+ 		buf[13]= 'P'; 		
+ 		// Create a byte array from 0x01, 0xFF - 0x00, 0x00
+ 		byte[] magic_trailer = new byte[258];
+ 		
+ 		magic_trailer[0] = 0x01;
+ 		
+ 		for(int i = 255; i >= 0; i--)
+ 			magic_trailer[256 - i] = (byte)i;
+ 		
+ 		// Insert XMP here
+ 		// Write extension introducer and application identifier
+ 		os.write(buf);
+ 		// Write the XMP packet
+ 		Document doc = getXmpDocument();
+		XMLUtils.insertLeadingPI(doc, "xpacket", "begin='' id='W5M0MpCehiHzreSzNTczkc9d'");
+		XMLUtils.insertTrailingPI(doc, "xpacket", "end='r'");
+		os.write(XMLUtils.serializeToByteArray(doc));
+ 		// Write the magic trailer
+ 		os.write(magic_trailer);
+ 		// End of XMP data 		
+	}
+}
diff --git a/src/pixy/meta/icc/ICCProfile.java b/src/pixy/meta/icc/ICCProfile.java
new file mode 100644
index 0000000..45ad5d6
--- /dev/null
+++ b/src/pixy/meta/icc/ICCProfile.java
@@ -0,0 +1,365 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * ICCProfile.java
+ *
+ * Who   Date       Description
+ * ====  =========  =====================================================
+ * WY    13Mar2015  Initial creation
+ */
+
+package pixy.meta.icc;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.meta.Metadata;
+import pixy.meta.MetadataEntry;
+import pixy.meta.MetadataType;
+import pixy.meta.icc.ProfileTagTable.TagEntry;
+import pixy.string.StringUtils;
+import pixy.io.IOUtils;
+
+/**
+ * International Color Consortium Profile (ICC Profile)
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 07/02/2013
+ */
+public class ICCProfile extends Metadata {
+	// Profile header - 128 bytes in length and contains 18 fields
+	private static class ICCProfileHeader {
+		private long profileSize;
+		private byte[] preferredCMMType = new byte[4];
+		private byte[] profileVersionNumber = new byte[4];
+		private int profileClass;
+		private byte[] colorSpace = new byte[4];
+		private byte[] PCS = new byte[4];
+		private byte[] dateTimeCreated = new byte[12];
+		private byte[] profileFileSignature = new byte[4]; // "acsp" 61637370h
+		private byte[] primaryPlatformSignature = new byte[4];
+		private byte[] profileFlags = new byte[4];
+		private byte[] deviceManufacturer = new byte[4];
+		private byte[] deviceModel = new byte[4];
+		private byte[] deviceAttributes = new byte[8];
+		private int renderingIntent;
+		private byte[] PCSXYZ = new byte[12];
+		private byte[] profileCreator = new byte[4];
+		private byte[] profileID = new byte[16];
+		private byte[] bytesReserved = new byte[28];
+	}
+	public static final int TAG_TABLE_OFFSET = 128;
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(ICCProfile.class);
+
+	public static void showProfile(byte[] data) {
+		if(data != null && data.length > 0) {
+			ICCProfile icc_profile = new ICCProfile(data);
+			try {
+				icc_profile.read();
+				Iterator<MetadataEntry> iterator = icc_profile.iterator();
+				while(iterator.hasNext()) {
+					MetadataEntry item = iterator.next();
+					LOGGER.info(item.getKey() + ": " + item.getValue());
+					if(item.isMetadataEntryGroup()) {
+						String indent = "    ";
+						Collection<MetadataEntry> entries = item.getMetadataEntries();
+						for(MetadataEntry e : entries) {
+							LOGGER.info(indent + e.getKey() + ": " + e.getValue());
+						}			
+					}					
+				}
+			} catch (IOException e) {
+				e.printStackTrace();
+			}			
+		}
+	}
+	
+	public static void showProfile(InputStream is) {
+		try {
+			showProfile(IOUtils.inputStreamToByteArray(is));
+		} catch (IOException e) {
+			e.printStackTrace();
+		}
+	}
+	
+	private ICCProfileHeader header;	
+	private ProfileTagTable tagTable;
+	
+	public ICCProfile(byte[] profile) {
+		super(MetadataType.ICC_PROFILE, profile);
+		ensureDataRead();
+	}
+	
+	public ICCProfile(InputStream is) throws IOException {
+		this(IOUtils.inputStreamToByteArray(is));
+	}
+	
+	public boolean canBeUsedIndependently() {
+		return (((header.profileFlags[0]>>6)&0x01) == 0);
+	}
+	
+	public String getBytesReserved() {
+		return StringUtils.byteArrayToHexString(header.bytesReserved);
+	}
+	
+	public String getColorSpace() {
+		return new String(header.colorSpace).trim();
+	}
+	
+	public String getDateTimeCreated() {
+		int year = IOUtils.readUnsignedShortMM(header.dateTimeCreated, 0);
+		int month = IOUtils.readUnsignedShortMM(header.dateTimeCreated, 2);
+		int day = IOUtils.readUnsignedShortMM(header.dateTimeCreated, 4);
+		int hour = IOUtils.readUnsignedShortMM(header.dateTimeCreated, 6);
+		int minutes = IOUtils.readUnsignedShortMM(header.dateTimeCreated, 8);
+		int seconds = IOUtils.readUnsignedShortMM(header.dateTimeCreated, 10);
+		
+		return year + "/" + month + "/" + day + ", " + hour + ":" + minutes + ":" + seconds;
+	}
+	
+	public String getDeviceAttributes() {
+		return (isReflective()?"reflective":"transparency") + ", " + (isGlossy()?"glossy":"matte") + ", " + (isPositive()?"positive":"negative") + ", " + (isColor()?"color":"black & white");
+	}
+	
+	public String getDeviceManufacturer() {
+		return new String(header.deviceManufacturer).trim();
+	}
+	
+	public String getDeviceModel() {
+		return new String(header.deviceModel).trim();
+	}
+	
+	public String getPCS() {
+		return new String(header.PCS).trim();
+	}
+	
+	public float[] getPCSXYZ() {
+		float PCSX = IOUtils.readS15Fixed16MMNumber(header.PCSXYZ, 0);
+		float PCSY = IOUtils.readS15Fixed16MMNumber(header.PCSXYZ, 4);
+		float PCSZ = IOUtils.readS15Fixed16MMNumber(header.PCSXYZ, 8);
+		
+		return new float[] {PCSX, PCSY, PCSZ};
+	}
+	
+	public String getPreferredCMMType() {
+		return new String(header.preferredCMMType).trim();
+	}
+	
+	public String getPrimaryPlatformSignature() {
+		return new String(header.primaryPlatformSignature).trim();
+	}
+	
+	public String getProfileClass() {
+		switch(header.profileClass) {
+			case 0x73636E72:
+				return "scnr";
+			case 0x6D6E7472:
+				return "mntr";
+			case 0x70727472:
+				return "prtr";
+			case 0x6C696E6B:
+				return "link";
+			case 0x73706163:
+				return "spac";
+			case 0x61627374:
+				return "abst";
+			case 0x6E6D636C:
+				return "nmcl";
+			default:
+				return "unknown";
+		}
+	}
+	
+	public String getProfileClassDescription() {
+		switch(header.profileClass) {
+			case 0x73636E72:
+				return "'scnr': input devices - scanners and digital cameras";
+			case 0x6D6E7472:
+				return "'mntr': display devices - CRTs and LCDs";
+			case 0x70727472:
+				return "'prtr': output devices - printers";
+			case 0x6C696E6B:
+				return "'link': device link profiles";
+			case 0x73706163:
+				return "'spac': color space conversion profiles";
+			case 0x61627374:
+				return "'abst': abstract profiles";
+			case 0x6E6D636C:
+				return "'nmcl': named color profiles";
+			default:
+				throw new IllegalArgumentException("Unknown profile/device class: " + header.profileClass);
+		}
+	}
+	
+	public String getProfileCreator() {
+		return new String(header.profileCreator).trim();
+	}
+	
+	public String getProfileFileSignature() {
+		return new String(header.profileFileSignature).trim();
+	}
+	
+	public String getProfileFlags() {
+		return (isEmbeddedInFile()?"embedded in file":"not embedded") + ", " + (canBeUsedIndependently()?"used independently":"cannot be used independently");
+	}
+	
+	public String getProfileID() {
+		return StringUtils.byteArrayToHexString(header.profileID);
+	}
+	
+	public long getProfileSize() {
+		return header.profileSize;
+	}
+	
+	public String getProfileVersionNumber() {
+		int majorVersion = (header.profileVersionNumber[0]&0xff);
+		int minorRevision = ((header.profileVersionNumber[1]>>4)&0x0f);
+		int bugFix = (header.profileVersionNumber[1]&0x0f);
+		
+		return "" + majorVersion + "." + minorRevision + bugFix;			
+	}
+	
+	public int getRenderingIntent() {
+		return header.renderingIntent&0x0000ffff;
+	}
+	
+	public String getRenderingIntentDescription() {
+		switch(header.renderingIntent&0x0000ffff) {
+			case 0:
+				return "perceptual";
+			case 1:
+				return "media-relative colorimetric";
+			case 2:
+				return "saturation";
+			case 3:
+				return "ICC-absolute colorimetric";
+			default:
+				throw new IllegalArgumentException("Unknown rendering intent: " + (header.renderingIntent&0x0000ffff));
+		}
+	}
+	
+	public ProfileTagTable getTagTable() {
+		return tagTable;
+	}
+	
+	public boolean isColor() {
+		return (((header.deviceAttributes[0]>>4)&0x01) == 0);
+	}
+	
+	public boolean isEmbeddedInFile() {
+		return (((header.profileFlags[0]>>7)&0x01) == 1);
+	}
+	
+	public boolean isGlossy() {
+		return (((header.deviceAttributes[0]>>6)&0x01) == 0);
+	}
+	
+	public boolean isPositive() {
+		return (((header.deviceAttributes[0]>>5)&0x01) == 0);
+	}
+		
+	public boolean isReflective() {
+		return (((header.deviceAttributes[0]>>7)&0x01) == 0);
+	}
+	
+	public Iterator<MetadataEntry> iterator() {
+		ensureDataRead();
+		List<MetadataEntry> entries = new ArrayList<MetadataEntry>();
+		MetadataEntry header = new MetadataEntry("ICC Profile", "Header", true);
+		header.addEntry(new MetadataEntry("Profile Size", getProfileSize() + ""));
+		header.addEntry(new MetadataEntry("CMM Type", getPreferredCMMType()));
+		header.addEntry(new MetadataEntry("Version", getProfileVersionNumber() + ""));
+		header.addEntry(new MetadataEntry("Profile/Device Class", getProfileClassDescription()));
+		header.addEntry(new MetadataEntry("Color Space", getColorSpace()));
+		header.addEntry(new MetadataEntry("PCS", getPCS()));
+		header.addEntry(new MetadataEntry("Date Created", getDateTimeCreated()));
+		header.addEntry(new MetadataEntry("Profile File Signature", getProfileFileSignature()));
+		header.addEntry(new MetadataEntry("Primary Platform Signature", getPrimaryPlatformSignature()));
+		header.addEntry(new MetadataEntry("Flags", getProfileFlags()));
+		header.addEntry(new MetadataEntry("Device Manufacturer", getDeviceManufacturer()));
+		header.addEntry(new MetadataEntry("Device Model", getDeviceModel()));
+		header.addEntry(new MetadataEntry("Device Attributes", getDeviceAttributes()));
+		header.addEntry(new MetadataEntry("Rendering Intent", getRenderingIntentDescription()));
+		header.addEntry(new MetadataEntry("PCS Illuminant [X]", getPCSXYZ()[0] + ""));
+		header.addEntry(new MetadataEntry("PCS Illuminant [Y]", getPCSXYZ()[1] + ""));
+		header.addEntry(new MetadataEntry("PCS Illuminant [Z]", getPCSXYZ()[2] + ""));
+		header.addEntry(new MetadataEntry("Profile Creator", getProfileCreator()));
+		header.addEntry(new MetadataEntry("Profile ID", getProfileID()));
+	
+		entries.add(header);
+		
+		MetadataEntry tagTableEntry = new MetadataEntry("ICC Profile", "Tag Table", true);
+		tagTableEntry.addEntry(new MetadataEntry("Tag Count", tagTable.getTagCount() + ""));
+		
+		List<TagEntry> tagEntries = tagTable.getTagEntries();
+		Collections.sort(tagEntries);
+		
+		for(TagEntry entry : tagEntries) {
+			tagTableEntry.addEntry(new MetadataEntry("Tag Name", ProfileTag.fromInt(entry.getProfileTag()) + ""));
+			tagTableEntry.addEntry(new MetadataEntry("Data Offset", entry.getDataOffset() + ""));
+			tagTableEntry.addEntry(new MetadataEntry("Data Length", entry.getDataLength() + ""));
+		}
+		
+		entries.add(tagTableEntry);
+	
+		return Collections.unmodifiableCollection(entries).iterator();
+	}
+	
+	public void read() throws IOException {
+		if(!isDataRead) {
+			this.header = new ICCProfileHeader();
+			this.tagTable = new ProfileTagTable();
+			readHeader(data);
+			readTagTable(data);
+			isDataRead = true;
+		}
+	}
+	
+	private void readHeader(byte[] data) {
+		header.profileSize = IOUtils.readUnsignedIntMM(data, 0);
+		System.arraycopy(data, 4, header.preferredCMMType, 0, 4);
+		System.arraycopy(data, 8, header.profileVersionNumber, 0, 4);
+		header.profileClass = IOUtils.readIntMM(data, 12);
+		System.arraycopy(data, 16, header.colorSpace, 0, 4);
+		System.arraycopy(data, 20, header.PCS, 0, 4);
+		System.arraycopy(data, 24, header.dateTimeCreated, 0, 12);
+		System.arraycopy(data, 36, header.profileFileSignature, 0, 4);
+		System.arraycopy(data, 40, header.primaryPlatformSignature, 0, 4);
+		System.arraycopy(data, 44, header.profileFlags, 0, 4);
+		System.arraycopy(data, 48, header.deviceManufacturer, 0, 4);
+		System.arraycopy(data, 52, header.deviceModel, 0, 4);
+		System.arraycopy(data, 56, header.deviceAttributes, 0, 8);
+		header.renderingIntent = IOUtils.readIntMM(data, 64);
+		System.arraycopy(data, 68, header.PCSXYZ, 0, 12);
+		System.arraycopy(data, 80, header.profileCreator, 0, 4);
+		System.arraycopy(data, 84, header.profileID, 0, 16);
+		System.arraycopy(data, 100, header.bytesReserved, 0, 28);
+	}
+	
+	private void readTagTable(byte[] data) {
+		tagTable.read(data);
+	}
+}
diff --git a/src/pixy/meta/icc/ProfileTag.java b/src/pixy/meta/icc/ProfileTag.java
new file mode 100644
index 0000000..98e6db0
--- /dev/null
+++ b/src/pixy/meta/icc/ProfileTag.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.icc;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.meta.icc.ProfileTag;
+
+/**
+ * ICC Profile Tag
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2015
+ */
+public enum ProfileTag {
+	// Public tags
+	A2B0(TagType.PUBLIC, 0x41324230, "AToB0Tag"), // Multi-dimensional transformation structure
+	A2B1(TagType.PUBLIC, 0x41324231, "AToB1Tag"), // Multi-dimensional transformation structure
+	A2B2(TagType.PUBLIC, 0x41324232, "AToB2Tag"), // Multi-dimensional transformation structure
+	bXYZ(TagType.PUBLIC, 0x6258595A, "blueMatrixColumnTag"), // The third column in the matrix used in matrix/TRC transforms. (This column is combined with the linear blue channel during the matrix multiplication).
+	bTRC(TagType.PUBLIC, 0x62545243, "blueTRCTag"), // Blue channel tone reproduction curve
+	B2A0(TagType.PUBLIC, 0x42324130, "BToA0Tag"), // Multi-dimensional transformation structure
+	B2A1(TagType.PUBLIC, 0x42324131, "BToA1Tag"), // Multi-dimensional transformation structure
+	B2A2(TagType.PUBLIC, 0x42324132, "BToA2Tag"), // Multi-dimensional transformation structure
+	B2D0(TagType.PUBLIC, 0x42324430, "BToD0Tag"), // Multi-dimensional transformation structure
+	B2D1(TagType.PUBLIC, 0x42324431, "BToD1Tag"), // Multi-dimensional transformation structure
+	B2D2(TagType.PUBLIC, 0x42324432, "BToD2Tag"), // Multi-dimensional transformation structure
+	B2D3(TagType.PUBLIC, 0x42324433, "BToD3Tag"), // Multi-dimensional transformation structure
+	BKPT(TagType.PUBLIC, 0x626b7074, "mediaBlackPointTag"), // nCIEXYZ of media black point
+	calt(TagType.PUBLIC, 0x63616C74, "calibrationDateTimeTag"), // Profile calibration date and time
+	chad(TagType.PUBLIC, 0x63686164, "chromaticAdaptationTag"), // Converts an nCIEXYZ colour relative to the actual adopted white to the nCIEXYZ colour relative to the PCS adopted white. Required only if the chromaticity of the actual adopted white is different from that of the PCS adopted white.
+	chrm(TagType.PUBLIC, 0x6368726D, "chromaticityTag"), // Set of phosphor/colorant chromaticity
+	clro(TagType.PUBLIC, 0x636C726F, "colorantOrderTag"), // Identifies the laydown order of colorants
+	clrt(TagType.PUBLIC, 0x636C7274, "colorantTableTag"), // Identifies the colorants used in the profile. Required for N-component based Output profiles and DeviceLink profiles only if the data colour space field is xCLR (e.g. 3CLR)
+	clot(TagType.PUBLIC, 0x636C6F74, "colorantTableOutTag"), // Identifies the output colorants used in the profile, required only if the PCS Field is xCLR (e.g. 3CLR)
+	ciis(TagType.PUBLIC, 0x63696973, "colorimetricIntentImageStateTag"), // Indicates the image state of PCS colorimetry produced using the colorimetric intent transforms
+	cprt(TagType.PUBLIC, 0x63707274, "copyrightTag"), // Profile copyright information
+	desc(TagType.PUBLIC, 0x64657363, "profileDescriptionTag"), // Structure containing invariant and localizable versions of the profile name for displays
+	desm(TagType.PRIVATE, 0x6473636d, "appleMultilanguageDescriptionTag"),
+	dmnd(TagType.PUBLIC, 0x646D6E64, "deviceMfgDescTag"), // Displayable description of device manufacturer
+	dmdd(TagType.PUBLIC, 0x646D6464, "deviceModelDescTag"), // Displayable description of device model
+	D2B0(TagType.PUBLIC, 0x44324230, "DToB0Tag"), // Multi-dimensional transformation structure
+	D2B1(TagType.PUBLIC, 0x44324231, "DToB1Tag"), // Multi-dimensional transformation structure
+	D2B2(TagType.PUBLIC, 0x44324232, "DToB2Tag"), // Multi-dimensional transformation structure
+	D2B3(TagType.PUBLIC, 0x44324233, "DToB3Tag"), // Multi-dimensional transformation structure
+	gamt(TagType.PUBLIC, 0x67616D74, "gamutTag"), // Out of gamut: 8-bit or 16-bit data
+	gTRC(TagType.PUBLIC, 0x67545243, "greenTRCTag"), // Green channel tone reproduction curve
+	gXYZ(TagType.PUBLIC, 0x6758595A, "greenMatrixColumnTag"), // The second column in the matrix used in matrix/TRC transforms (This column is combined with the linear green channel during the matrix multiplication).
+	kTRC(TagType.PUBLIC, 0x6B545243, "grayTRCTag"), // Grey tone reproduction curve
+	lumi(TagType.PUBLIC, 0x6C756D69, "luminanceTag"), // Absolute luminance for emissive device
+	meas(TagType.PUBLIC, 0x6D656173, "measurementTag"), // Alternative measurement specification information
+	mmod(TagType.PRIVATE, 0x6d6d6f64, "MakeAndModel"),
+	ncl2(TagType.PUBLIC, 0x6E636C32, "namedColor2Tag"), // PCS and optional device representation for named colours
+	pre0(TagType.PUBLIC, 0x70726530, "preview0Tag"), // Preview transformation: 8-bit or 16-bit data
+	pre1(TagType.PUBLIC, 0x70726531, "preview1Tag"), // Preview transformation: 8-bit or 16-bit data
+	pre2(TagType.PUBLIC, 0x70726532, "preview2Tag"), // Preview transformation: 8-bit or 16-bit data
+	pseq(TagType.PUBLIC, 0x70736571, "profileSequenceDescTag"), // An array of descriptions of the profile sequence
+	psid(TagType.PUBLIC, 0x70736964, "profileSequenceIdentifierTag"),
+	resp(TagType.PUBLIC, 0x72657370, "outputResponseTag"), //	Description of the desired device response
+	rigo(TagType.PUBLIC, 0x72696730, "perceptualRenderingIntentGamutTag"), // When present, the specified gamut is defined to be the reference medium gamut for the PCS side of both the A2B0 and B2A0 tags
+	rig2(TagType.PUBLIC, 0x72696732, "saturationRenderingIntentGamutTag"), //	When present, the specified gamut is defined to be the reference medium gamut for the PCS side of both the A2B2 and B2A2 tags
+	rXYZ(TagType.PUBLIC, 0x7258595A, "redMatrixColumnTag"), // The first column in the matrix used in matrix/TRC transforms. (This column is combined with the linear red channel during the matrix multiplication).
+	rTRC(TagType.PUBLIC, 0x72545243, "redTRCTag"), //	Red channel tone reproduction curve
+	targ(TagType.PUBLIC, 0x74617267, "charTargetTag"), //	Characterization target such as IT8/7.2
+	tech(TagType.PUBLIC, 0x74656368, "technologyTag"), //	Device technology information such as LCD, CRT, Dye Sublimation, etc.
+	vcgt(TagType.PRIVATE, 0x76636774, "VideoCardGammaType"),
+	vued(TagType.PUBLIC, 0x76756564, "viewingCondDescTag"), // Viewing condition description
+	view(TagType.PUBLIC, 0x76696577, "viewingConditionsTag"), // Viewing condition parameters
+	wtpt(TagType.PUBLIC, 0x77747074, "mediaWhitePointTag"), // nCIEXYZ of media white point
+
+	UNKNOWN(TagType.UNKNOWN, 0xFFFFFFFF, "UnknownTag");
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(ProfileTag.class);
+
+	public enum TagType { //
+		PUBLIC,
+		PRIVATE,
+		UNKNOWN;		
+	}
+	
+	public TagType getTagType() {
+		return tagType;
+	}
+	
+	private ProfileTag(TagType tagType, int value, String description) {
+		this.description = description;
+		this.value = value;
+		this.tagType = tagType;
+	}	
+	
+	public String getDescription() {
+		return description;
+	}
+	
+	public int getValue() {
+		return value;
+	}
+	
+	@Override
+    public String toString() {
+		return name() + " (" + description + ")";
+	}
+	
+    public static ProfileTag fromInt(int value) {
+       	ProfileTag tag = typeMap.get(value);
+    	if (tag == null) {
+    	 LOGGER.warn("tag value 0x{} unknown", Integer.toHexString(value));
+    		return UNKNOWN;
+    	}
+   		return tag;
+    }
+    
+    private static final Map<Integer, ProfileTag> typeMap = new HashMap<Integer, ProfileTag>();
+       
+    static
+    {
+      for(ProfileTag tagSignature : values())
+           typeMap.put(tagSignature.getValue(), tagSignature);
+    } 
+	
+    private final TagType tagType;
+	private final String description;
+	private final int value;
+}
diff --git a/src/pixy/meta/icc/ProfileTagTable.java b/src/pixy/meta/icc/ProfileTagTable.java
new file mode 100644
index 0000000..33502d0
--- /dev/null
+++ b/src/pixy/meta/icc/ProfileTagTable.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.icc;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.meta.icc.ICCProfile;
+import pixy.meta.icc.ProfileTag;
+import pixy.io.IOUtils;
+
+/**
+ * ICC Profile Tag Table
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2015
+ */
+public class ProfileTagTable {
+	private int tagCount;
+	private Map<Integer, TagEntry> tagEntries = new HashMap<Integer, TagEntry>();
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(ProfileTagTable.class);
+		
+	public static class TagEntry implements Comparable<TagEntry> {
+		private int profileTag;
+		private int dataOffset;
+		private int dataLength;
+		private byte[] data;
+		
+		public TagEntry(int profileTag, int dataOffset, int dataLength, byte[] data) {
+			this.profileTag  = profileTag;
+			this.dataOffset = dataOffset;
+			this.dataLength = dataLength;
+			this.data = data;
+		}
+		
+		public int compareTo(TagEntry o) {
+			return (int)((this.profileTag&0xffffffffL) - (o.profileTag&0x0ffffffffL));
+		}
+		
+		public int getProfileTag() {
+			return profileTag;
+		}
+		
+		public int getDataOffset() {
+			return dataOffset;
+		}
+		
+		public int getDataLength() {
+			return dataLength;
+		}
+		
+		public byte[] getData() {
+			return data;
+		}		
+	}
+	
+	public ProfileTagTable() {}
+	
+	public void addTagEntry(TagEntry tagEntry) {
+		tagEntries.put(tagEntry.getProfileTag(), tagEntry);
+	}
+	
+	public void read(byte[] data) {
+		int offset = ICCProfile.TAG_TABLE_OFFSET;
+		tagCount = IOUtils.readIntMM(data, offset);
+		offset += 4;
+		// Read each tag
+		for(int i = 0; i < tagCount; i++) {
+			int tagSignature = IOUtils.readIntMM(data, offset);
+			offset += 4;
+			ProfileTag tag = ProfileTag.fromInt(tagSignature);
+			int dataOffset = IOUtils.readIntMM(data, offset);
+			offset += 4;
+			int dataLength = IOUtils.readIntMM(data, offset);
+			offset += 4;
+			
+			byte[] temp = new byte[dataLength];
+			System.arraycopy(data, dataOffset, temp, 0, temp.length);
+			
+			tagEntries.put(tagSignature, new TagEntry(tag.getValue(), dataOffset, dataLength, temp));
+		}
+	}
+	
+	public int getTagCount() {
+		return tagCount;
+	}
+	
+	public TagEntry getTagEntry(ProfileTag profileTag) {
+		return tagEntries.get(profileTag.getValue());
+	}
+	
+	public List<TagEntry> getTagEntries() {
+		return new ArrayList<TagEntry>(tagEntries.values());
+	}
+	
+	public void showTable() {
+		StringBuilder profileTable = new StringBuilder();
+		profileTable.append("*** Start of ICC_Profile Tag Table ***\n");
+		profileTable.append("Tag Count: " + tagCount + "\n");
+		
+		List<TagEntry> list = getTagEntries();
+		Collections.sort(list);
+		int count = 0;
+	
+		for(TagEntry tagEntry:list) {
+			profileTable.append("Tag# " + count++);
+			profileTable.append(", Tag Name: " + ProfileTag.fromInt(tagEntry.getProfileTag()));
+			profileTable.append(", Data Offset: " + tagEntry.getDataOffset());
+			profileTable.append(", Data Length: " + tagEntry.getDataLength() + "\n");
+		}
+		profileTable.append("*** End of ICC_Profile Tag Table ***\n");
+		
+		LOGGER.info("\n{}", profileTable);
+	}
+}
diff --git a/src/pixy/meta/image/Comments.java b/src/pixy/meta/image/Comments.java
new file mode 100644
index 0000000..1753e5a
--- /dev/null
+++ b/src/pixy/meta/image/Comments.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * Comments.java
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    06Nov2015  Initial creation
+ */
+
+package pixy.meta.image;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Queue;
+
+import pixy.meta.Metadata;
+import pixy.meta.MetadataEntry;
+import pixy.meta.MetadataType;
+
+public class Comments extends Metadata {
+	private Queue<byte[]> queue;
+	private List<String> comments;
+	
+	public Comments() {
+		super(MetadataType.COMMENT);
+		queue = new LinkedList<byte[]>();
+		comments = new ArrayList<String>();
+	}
+	
+	public Comments(List<String> comments) {
+		super(MetadataType.COMMENT);
+		queue = new LinkedList<byte[]>();
+		if(comments == null) throw new IllegalArgumentException("Input is null");
+		this.comments = comments;
+	}
+
+	public List<String> getComments() {
+		ensureDataRead();
+		return Collections.unmodifiableList(comments);
+	}
+	
+	public void addComment(byte[] comment) {
+		if(comment == null) throw new IllegalArgumentException("Input is null");
+		queue.offer(comment);
+	}
+	
+	public void addComment(String comment) {
+		if(comment == null) throw new IllegalArgumentException("Input is null");
+		comments.add(comment);
+	}
+	
+	public Iterator<MetadataEntry> iterator() {
+		ensureDataRead();
+		List<MetadataEntry> entries = new ArrayList<MetadataEntry>();
+			
+		for (String comment : comments)
+		    entries.add(new MetadataEntry(comment, "")); // For comments, we set the value to empty string
+		
+		return Collections.unmodifiableCollection(entries).iterator();
+	}
+	
+	public void read() throws IOException {
+		if(queue.size() > 0) {
+			for(byte[] comment : queue) {
+				try {
+					comments.add(new String(comment, "UTF-8"));
+				} catch (UnsupportedEncodingException e) {
+					throw new UnsupportedEncodingException("UTF-8");
+				}
+			}
+			queue.clear();
+		}
+	}
+}
diff --git a/src/pixy/meta/image/ImageMetadata.java b/src/pixy/meta/image/ImageMetadata.java
new file mode 100644
index 0000000..33b312a
--- /dev/null
+++ b/src/pixy/meta/image/ImageMetadata.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * ImageMetadata.java
+ *
+ * Who   Date       Description
+ * ====  =========  =====================================================
+ * WY    13Mar2015  Initial creation
+*/
+
+package pixy.meta.image;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.Map;
+
+import pixy.meta.Metadata;
+import pixy.meta.MetadataEntry;
+import pixy.meta.MetadataType;
+import pixy.meta.Thumbnail;
+
+public class ImageMetadata extends Metadata {
+	private Map<String, Thumbnail> thumbnails;
+	private Collection<MetadataEntry> entries = new ArrayList<MetadataEntry>();
+
+	public ImageMetadata() {
+		super(MetadataType.IMAGE);
+	}
+	
+	public ImageMetadata(Map<String, Thumbnail> thumbnails) {
+		super(MetadataType.IMAGE);
+		this.thumbnails = thumbnails;
+	}
+	
+	public void addMetadataEntry(MetadataEntry entry) {
+		entries.add(entry);
+	}
+	
+	public void addMetadataEntries(Collection<MetadataEntry> entries) {
+		entries.addAll(entries);
+	}
+	
+	public boolean containsThumbnail() {
+		return thumbnails != null && thumbnails.size() > 0;
+	}
+	
+	public Map<String, Thumbnail> getThumbnails() {
+		return thumbnails;
+	}
+	
+	public Iterator<MetadataEntry> iterator() {
+		if(containsThumbnail()) { // We have thumbnail
+			Iterator<Map.Entry<String, Thumbnail>> mapEntries = thumbnails.entrySet().iterator();
+			entries.add(new MetadataEntry("Total number of thumbnails", "" + thumbnails.size()));
+			int i = 0;
+			while (mapEntries.hasNext()) {
+			    Map.Entry<String, Thumbnail> entry = mapEntries.next();
+			    MetadataEntry e = new MetadataEntry("Thumbnail " + i, entry.getKey(), true);
+			    Thumbnail thumbnail = entry.getValue();
+			    e.addEntry(new MetadataEntry("Thumbnail width", ((thumbnail.getWidth() < 0)? " Unavailable": ""+ thumbnail.getWidth())));
+				e.addEntry(new MetadataEntry("Thumbnail height", ((thumbnail.getHeight() < 0)? " Unavailable": "" + thumbnail.getHeight())));
+				e.addEntry(new MetadataEntry("Thumbnail data type", thumbnail.getDataTypeAsString()));
+				entries.add(e);
+				i++;
+			}
+		}		
+		return Collections.unmodifiableCollection(entries).iterator();
+	}
+	
+	public void read() throws IOException {
+		if(!isDataRead)
+			// No implementation
+			isDataRead = true;
+	}	
+}
diff --git a/src/pixy/meta/iptc/IPTC.java b/src/pixy/meta/iptc/IPTC.java
new file mode 100644
index 0000000..c614396
--- /dev/null
+++ b/src/pixy/meta/iptc/IPTC.java
@@ -0,0 +1,245 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * IPTC.java
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    25Apr2015  Renamed getDataSet() to getDataSets()
+ * WY    25Apr2015  Added addDataSets()
+ * WY    13Apr2015  Added write()
+ */
+
+package pixy.meta.iptc;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.Map.Entry;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.meta.Metadata;
+import pixy.meta.MetadataEntry;
+import pixy.meta.MetadataType;
+import pixy.io.IOUtils;
+
+public class IPTC extends Metadata {
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(IPTC.class);
+	
+	public static void showIPTC(byte[] data) {
+		if(data != null && data.length > 0) {
+			IPTC iptc = new IPTC(data);
+			try {
+				iptc.read();
+				Iterator<MetadataEntry> iterator = iptc.iterator();
+				while(iterator.hasNext()) {
+					MetadataEntry item = iterator.next();
+					LOGGER.info(item.getKey() + ": " + item.getValue());
+					if(item.isMetadataEntryGroup()) {
+						String indent = "    ";
+						Collection<MetadataEntry> entries = item.getMetadataEntries();
+						for(MetadataEntry e : entries) {
+							LOGGER.info(indent + e.getKey() + ": " + e.getValue());
+						}			
+					}					
+				}
+			} catch (IOException e) {
+				e.printStackTrace();
+			}
+		}
+	}
+	public static void showIPTC(InputStream is) {
+		try {
+			showIPTC(IOUtils.inputStreamToByteArray(is));
+		} catch (IOException e) {
+			e.printStackTrace();
+		}
+	}
+	
+	private Map<IPTCTag, List<IPTCDataSet>> datasetMap;
+	
+	public IPTC() {
+		super(MetadataType.IPTC);
+		datasetMap =  new TreeMap<IPTCTag, List<IPTCDataSet>>(new IPTCTagComparator());
+		isDataRead = true;
+	}
+	
+	public IPTC(byte[] data) {
+		super(MetadataType.IPTC, data);
+		ensureDataRead();
+	}
+	
+	public void addDataSet(IPTCDataSet dataSet) {
+		if(datasetMap != null) {
+			IPTCTag tag = dataSet.getTagEnum();
+			if(datasetMap.get(tag) == null) {
+				List<IPTCDataSet> list = new ArrayList<IPTCDataSet>();
+				list.add(dataSet);
+				datasetMap.put(tag, list);
+			} else if(dataSet.allowMultiple()) {
+				datasetMap.get(tag).add(dataSet);
+			}
+		} else throw new IllegalStateException("DataSet Map is empty");
+	}
+	
+	public void addDataSets(Collection<? extends IPTCDataSet> dataSets) {
+		if(datasetMap != null) {
+			for(IPTCDataSet dataSet: dataSets) {
+				IPTCTag tag = dataSet.getTagEnum();
+				if(datasetMap.get(tag) == null) {
+					List<IPTCDataSet> list = new ArrayList<IPTCDataSet>();
+					list.add(dataSet);
+					datasetMap.put(tag, list);
+				} else if(dataSet.allowMultiple()) {
+					datasetMap.get(tag).add(dataSet);
+				}
+			}
+		} else throw new IllegalStateException("DataSet Map is empty");
+	}
+	
+	/**
+	 * Get a string representation of the IPTCDataSet associated with the key
+	 *  
+	 * @param key the IPTCTag for the IPTCDataSet
+	 * @return a String representation of the IPTCDataSet, separated by ";"
+	 */	
+	public String getAsString(IPTCTag key) {
+		// Retrieve the IPTCDataSet list associated with this key
+		// Most of the time the list will only contain one item
+		List<IPTCDataSet> list = getDataSet(key);
+		
+		String value = "";
+	
+		if(list != null) {
+			if(list.size() == 1) {
+				value = list.get(0).getDataAsString();
+			} else {
+				for(int i = 0; i < list.size() - 1; i++)
+					value += list.get(i).getDataAsString() + ";";
+				value += list.get(list.size() - 1).getDataAsString();
+			}
+		}
+			
+		return value;
+	}
+	
+	/**
+	 * Get a list of IPTCDataSet associated with a key
+	 * 
+	 * @param key IPTCTag of the DataSet
+	 * @return a list of IPTCDataSet associated with the key
+	 */
+	public List<IPTCDataSet> getDataSet(IPTCTag key) {
+		return getDataSets().get(key);
+	}
+	
+	/**
+	 * Get all the IPTCDataSet as a map for this IPTC data
+	 * 
+	 * @return a map with the key for the IPTCDataSet tag and a list of IPTCDataSet as the value
+	 */
+	public Map<IPTCTag, List<IPTCDataSet>> getDataSets() {
+		ensureDataRead();
+		return datasetMap;
+	}
+	
+	public Iterator<MetadataEntry> iterator() {
+		ensureDataRead();
+		if(datasetMap != null){
+			// Print multiple entry IPTCDataSet
+			Set<Map.Entry<IPTCTag, List<IPTCDataSet>>> entries = datasetMap.entrySet();
+			final Iterator<Entry<IPTCTag, List<IPTCDataSet>>> iter = entries.iterator();
+			return new Iterator<MetadataEntry>() {
+				public MetadataEntry next() {
+					Entry<IPTCTag, List<IPTCDataSet>> entry = iter.next();
+					String key = entry.getKey().getName();
+					String value = "";
+					
+					for(IPTCDataSet item : entry.getValue()) {
+						value += ";" + item.getDataAsString();
+					}
+					
+					return new MetadataEntry(key, value.replaceFirst(";", ""));
+			    }
+
+			    public boolean hasNext() {
+			    	return iter.hasNext();
+			    }
+
+			    public void remove() {
+			    	throw new UnsupportedOperationException("Removing MetadataEntry is not supported by this Iterator");
+			    }
+			};
+		}
+		return Collections.emptyIterator();
+	}
+	
+	public void read() throws IOException {
+		if(!isDataRead) {
+			int i = 0;
+			int tagMarker = data[i];
+			datasetMap = new TreeMap<IPTCTag, List<IPTCDataSet>>(new IPTCTagComparator());
+			while (tagMarker == 0x1c) {
+				i++;
+				int recordNumber = data[i++]&0xff;
+				int tag = data[i++]&0xff;
+				int recordSize = IOUtils.readUnsignedShortMM(data, i);
+				i += 2;
+				
+				if(recordSize > 0) {
+					IPTCDataSet dataSet = new IPTCDataSet(recordNumber, tag, recordSize, data, i);
+					
+					IPTCTag tagEnum = dataSet.getTagEnum();
+					if(datasetMap.get(tagEnum) == null) {
+						List<IPTCDataSet> list = new ArrayList<IPTCDataSet>();
+						list.add(dataSet);
+						datasetMap.put(tagEnum, list);
+					} else
+						datasetMap.get(tagEnum).add(dataSet);
+				}
+			
+				i += recordSize;
+				// Sanity check
+				if(i >= data.length) break;	
+				tagMarker = data[i];							
+			}
+			// Remove possible duplicates
+			for (Map.Entry<IPTCTag, List<IPTCDataSet>> entry : datasetMap.entrySet()){
+			    entry.setValue(new ArrayList<IPTCDataSet>(new LinkedHashSet<IPTCDataSet>(entry.getValue())));
+			}
+			
+			isDataRead = true;
+		}
+	}
+	
+	public void write(OutputStream os) throws IOException {
+		for(List<IPTCDataSet> datasets : getDataSets().values())
+			for(IPTCDataSet dataset : datasets)
+				dataset.write(os);
+	}
+}
diff --git a/src/pixy/meta/iptc/IPTCApplicationTag.java b/src/pixy/meta/iptc/IPTCApplicationTag.java
new file mode 100644
index 0000000..eb3761e
--- /dev/null
+++ b/src/pixy/meta/iptc/IPTCApplicationTag.java
@@ -0,0 +1,246 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.iptc;
+
+import java.io.UnsupportedEncodingException;
+import java.util.HashMap;
+import java.util.Map;
+
+import pixy.meta.iptc.IPTCApplicationTag;
+import pixy.meta.iptc.IPTCTag;
+import pixy.string.StringUtils;
+
+/**
+ * Defines DataSet tags for IPTC Application Record - Record number 2.
+ * * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2015
+ */
+public enum IPTCApplicationTag implements IPTCTag {
+	 RECORD_VERSION(0, "ApplicationRecordVersion") {
+		 public String getDataAsString(byte[] data) {
+			 // Hex representation of the data
+			 return StringUtils.byteArrayToHexString(data);
+		 }
+	 },
+	 OBJECT_TYPE_REF(3, "ObjectTypeRef"),
+	 OBJECT_ATTR_REF(4, "ObjectAttribRef") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 OBJECT_NAME(5, "ObjectName"),
+	 EDIT_STATUS(7, "EditStatus"),
+	 EDITORIAL_UPDATE(8, "EditorialUpdate"),
+	 URGENCY(10, "Urgency"),
+	 SUBJECT_REF(12, "SubjectReference") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 CATEGORY(15, "Category"){
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 SUPP_CATEGORY(20, "SupplementalCategories") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 FIXTURE_ID(22, "FixtureID"),
+	 KEY_WORDS(25, "Keywords") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 CONTENT_LOCATION_CODE(26, "ContentLocationCode") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 CONTENT_LOCATION_NAME(27, "ContentLocationName") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 RELEASE_DATE(30, "ReleaseDate"),
+	 RELEASE_TIME(35, "ReleaseTime"),
+	 EXPIRATION_DATE(37, "ExpirationDate"),
+	 EXPIRATION_TIME(38, "ExpirationTime"),
+	 SPECIAL_INSTRUCTIONS(40, "SpecialInstructions"),
+	 ACTION_ADVISED(42, "ActionAdvised"),
+	 REFERENCE_SERVICE(45, "ReferenceService") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 REFERENCE_DATE(47, "ReferenceDate") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 REFERENCE_NUMBER(50, "ReferenceNumber") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 DATE_CREATED(55, "DateCreated"),
+	 TIME_CREATED(60, "TimeCreated"),
+	 DIGITAL_CREATION_DATE(62, "DigitalCreationDate"),
+	 DIGITAL_CREATION_TIME(63, "DigitalCreationTime"),
+	 ORIGINATING_PROGRAM(65, "OriginatingProgram"),
+	 PROGRAM_VERSION(70, "ProgramVersion"),
+	 OBJECT_CYCLE(75, "ObjectCycle"),
+	 BY_LINE(80, "ByLine") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 BY_LINE_TITLE(85, "ByLineTitle") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 CITY(90, "City"),
+	 SUB_LOCATION(92, "SubLocation"),
+	 PROVINCE_STATE(95, "ProvinceState"),
+	 COUNTRY_CODE(100, "CountryCode"),
+	 COUNTRY_NAME(101, "CountryName"),
+	 ORIGINAL_TRANSMISSION_REF(103, "OriginalTransmissionRef"),
+	 HEADLINE(105, "Headline"),
+	 CREDIT(110, "Credit") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 SOURCE(115, "Source"),
+	 COPYRIGHT_NOTICE(116, "CopyrightNotice") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 CONTACT(118, "Contact") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 CAPTION_ABSTRACT(120, "CaptionAbstract"),
+	 WRITER_EDITOR(122, "WriterEditor") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 RASTERIZED_CAPTION(125, "RasterizedCaption"),
+	 IMAGE_TYPE(130, "ImageType"),
+	 IMAGE_ORIENTATION(131, "ImageOrientation"),
+	 LANGUAGE_ID(135, "LanguageID"),
+	 AUDIO_TYPE(150, "AudioType"),
+	 AUDIO_SAMPLING_RATE(151, "AudioSamplingRate"),
+	 AUDIO_SAMPLING_RESOLUTION(152, "AudioSamplingResolution"),
+	 AUDIO_DURATION(153, "AudioDuration"),
+	 AUDIO_OUTCUE(154, "AudioOutcue"),
+	 OBJECT_DATA_PREVIEW_FILE_FORMAT(200, "ObjectDataPreviewFileFormat"),
+	 OBJECT_DATA_PREVIEW_FILE_FORMAT_VERSION(201, "ObjectDataPreviewFileFormatVersion"),
+	 OBJECT_DATA_PREVIEW_DATA(202, "ObjectDataPreviewData"),
+	 PHOTO_MECHANIC_PREFERENCES(221, "PhotoMechanicPreferences"),
+	 CLASSIFY_STATE(225, "ClassifyState"),
+	 SIMILARITY_INDEX(228, "SimilarityIndex"),
+	 DOCUMENT_NOTES(230, "DocumentNotes"),
+	 DOCUMENT_HISTORY(231, "DocumentHistory"),
+	 EXIF_CAMERA_INFO(232, "ExifCameraInfo"),
+	 CATALOG_SETS(255, "CatalogSets") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	  
+	 UNKNOWN(999, "Unknown");
+	 
+	 private IPTCApplicationTag(int tag, String name) {
+		 this.tag = tag;
+		 this.name = name;
+	 }
+	 
+	 public boolean allowMultiple() {
+		 return false;
+	 }
+	 
+	 // Default implementation. Could be replaced by individual ENUM
+	 public String getDataAsString(byte[] data) {
+		 try {
+			 String strVal = new String(data, "UTF-8").trim();
+			 if(strVal.length() > 0) return strVal;
+		 } catch (UnsupportedEncodingException e) {
+			 e.printStackTrace();
+		 }
+		 // Hex representation of the data
+		 return StringUtils.byteArrayToHexString(data, 0, IPTCTag.MAX_STRING_REPR_LEN);
+	 }
+	 
+	 public String getName() {
+		 return name;
+	 }
+	 
+	 
+	 public int getRecordNumber() {
+		 return IPTCRecord.APPLICATION.getRecordNumber();
+	 }
+	 
+	 public int getTag() { 
+		 return tag;
+	 }
+	 
+	 public static IPTCApplicationTag fromTag(int value) {
+      	IPTCApplicationTag record = recordMap.get(value);
+   	if (record == null)
+   		return UNKNOWN;
+   	return record;
+   }
+  
+   @Override public String toString() {
+	   return name;
+   }
+  
+   private static final Map<Integer, IPTCApplicationTag> recordMap = new HashMap<Integer, IPTCApplicationTag>();
+   
+   static
+   {
+     for(IPTCApplicationTag record : values()) {
+         recordMap.put(record.getTag(), record);
+     }
+   }	    
+ 
+   private final int tag;
+   private final String name;				
+}
diff --git a/src/pixy/meta/iptc/IPTCDataSet.java b/src/pixy/meta/iptc/IPTCDataSet.java
new file mode 100644
index 0000000..1f8a584
--- /dev/null
+++ b/src/pixy/meta/iptc/IPTCDataSet.java
@@ -0,0 +1,275 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * IPTCDataSet.java
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    16Aug2016  Added support for Unicode string data
+ * WY    16Jul2015  Added two new constructors for IPTCApplicationTag
+ * WY    13Mar2015  Initial creation
+ */
+
+package pixy.meta.iptc;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.Arrays;
+
+import pixy.meta.iptc.IPTCApplicationTag;
+import pixy.meta.iptc.IPTCDataSet;
+import pixy.meta.iptc.IPTCEnvelopeTag;
+import pixy.meta.iptc.IPTCFotoStationTag;
+import pixy.meta.iptc.IPTCNewsPhotoTag;
+import pixy.meta.iptc.IPTCObjectDataTag;
+import pixy.meta.iptc.IPTCPostObjectDataTag;
+import pixy.meta.iptc.IPTCPreObjectDataTag;
+import pixy.meta.iptc.IPTCRecord;
+import pixy.meta.iptc.IPTCTag;
+import pixy.io.IOUtils;
+import pixy.util.ArrayUtils;
+
+/**
+ * International Press Telecommunications Council (IPTC) data set
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 06/10/2013
+ */
+public class IPTCDataSet implements Comparable<IPTCDataSet> {
+	// Fields
+	private int recordNumber; // Corresponds to IPTCRecord enumeration recordNumber 
+	private int tag; // Corresponds to IPTC tag enumeration tag field
+	private int size;
+	private byte[] data;
+	private int offset;
+	private IPTCTag tagEnum;
+	
+	// A unique name used as HashMap key
+	private String name;
+	
+	private static final byte[] getBytes(String str) {
+		try {
+			return str.getBytes("UTF-8");
+		} catch (UnsupportedEncodingException e) {
+			throw new RuntimeException("Unsupported encoding UTF-8");
+		}
+	}
+	
+	public IPTCDataSet(int tag, byte[] data) {
+		this(IPTCRecord.APPLICATION, tag, data);
+	}
+	
+	public IPTCDataSet(int recordNumber, int tag, int size, byte[] data, int offset) {
+		this.recordNumber = recordNumber;
+		this.tag = tag;
+		this.size = size;
+		this.data = data;
+		this.offset = offset;		
+		this.name = getTagName();
+	}
+	
+	public IPTCDataSet(int tag, String value) {
+		this(tag, IPTCDataSet.getBytes(value));
+	}
+	
+	public IPTCDataSet(IPTCApplicationTag appTag, byte[] data) {
+		this(appTag.getTag(), data);
+	}
+	
+	public IPTCDataSet(IPTCApplicationTag appTag, String value) {
+		this(appTag.getTag(), value);
+	}
+	
+	public IPTCDataSet(IPTCRecord record, int tag, byte[] data) {
+		this(record.getRecordNumber(), tag, data.length, data, 0);
+	}
+	
+	public IPTCDataSet(IPTCRecord record, int tag, String value) {
+		this(record, tag, IPTCDataSet.getBytes(value));
+	}
+	
+	public boolean allowMultiple() {
+		return tagEnum.allowMultiple();
+	}
+	
+	@Override
+	public int compareTo(IPTCDataSet other) {
+		final int BEFORE = -1;
+	    final int EQUAL = 0;
+	    final int AFTER = 1;
+	    
+	    if(this == other) return EQUAL;
+	    
+	    if (this.getRecordNumber() < other.getRecordNumber()) return BEFORE;
+	    if (this.getRecordNumber() > other.getRecordNumber()) return AFTER;
+	    if(this.getRecordNumber() == other.getRecordNumber()) {
+	    	if (this.getTag() < other.getTag()) return BEFORE;
+		    if (this.getTag() > other.getTag()) return AFTER;
+		    return EQUAL;
+	    }
+	
+		return EQUAL;
+	}
+	
+	@Override
+	public boolean equals(Object obj) {
+		if (this == obj)
+			return true;
+		if (obj == null)
+			return false;
+		if (getClass() != obj.getClass())
+			return false;
+		IPTCDataSet other = (IPTCDataSet) obj;
+		byte[] thisData = ArrayUtils.subArray(data, offset, size);
+		byte[] thatData = ArrayUtils.subArray(other.data, other.offset, other.size);
+		if (!Arrays.equals(thisData, thatData))
+			return false;
+		if (recordNumber != other.recordNumber)
+			return false;
+		if (tag != other.tag)
+			return false;
+		return true;
+	}
+	
+	public byte[] getData() {
+		return ArrayUtils.subArray(data, offset, size);
+	}
+	
+	public String getDataAsString() {
+		return tagEnum.getDataAsString(getData());
+	}
+	
+	public String getName() {
+		return name;
+	}
+	
+	public String getRecordType() {
+		//
+		switch (recordNumber) {
+			case 1: //Envelope Record
+				return "Envelope Record";
+			case 2: //Application Record
+				return "Application Record";
+			case 3: //NewsPhoto Record
+				return "NewsPhoto Record";
+			case 7: //PreObjectData Record
+				return "PreObjectData Record";
+			case 8: //ObjectData Record
+				return "ObjectData Record";
+			case 9: //PostObjectData Record
+				return "PostObjectData Record";
+			case 240: //FotoStation Record
+				return "FotoStation Record";
+			default:
+				return "Unknown Record";
+		}
+	}
+	
+	public int getRecordNumber() {
+		return recordNumber;
+	}
+	
+	public int getSize() {
+		return size;
+	}
+	
+	public int getTag() {
+		return tag;
+	}
+	
+	public IPTCTag getTagEnum() {
+		return tagEnum;
+	}
+	
+	private String getTagName() {
+		switch(IPTCRecord.fromRecordNumber(recordNumber)) {
+			case APPLICATION:
+				tagEnum = IPTCApplicationTag.fromTag(tag);
+				break;
+			case ENVELOP:
+				tagEnum = IPTCEnvelopeTag.fromTag(tag);
+				break;
+			case FOTOSTATION:
+				tagEnum = IPTCFotoStationTag.fromTag(tag);
+				break;
+			case NEWSPHOTO:
+				tagEnum = IPTCNewsPhotoTag.fromTag(tag);
+				break;
+			case OBJECTDATA:
+				tagEnum = IPTCObjectDataTag.fromTag(tag);
+				break;
+			case POST_OBJECTDATA:
+				tagEnum = IPTCPostObjectDataTag.fromTag(tag);
+				break;
+			case PRE_OBJECTDATA:
+				tagEnum = IPTCPreObjectDataTag.fromTag(tag);
+				break;
+			case UNKNOWN:
+				switch(IPTCRecord.fromRecordNumber(recordNumber)) {
+					case APPLICATION:
+						tagEnum = IPTCApplicationTag.UNKNOWN;
+						break;
+					case ENVELOP:
+						tagEnum = IPTCEnvelopeTag.UNKNOWN;
+						break;
+					case NEWSPHOTO:
+						tagEnum = IPTCNewsPhotoTag.UNKNOWN;
+						break;
+					case PRE_OBJECTDATA:
+						tagEnum = IPTCPreObjectDataTag.UNKNOWN;
+						break;
+					case OBJECTDATA:
+						tagEnum = IPTCObjectDataTag.UNKNOWN;
+						break;
+					case POST_OBJECTDATA:
+						tagEnum = IPTCPostObjectDataTag.UNKNOWN;
+						break;
+					case FOTOSTATION:
+						tagEnum = IPTCFotoStationTag.UNKNOWN;
+						break;
+					case UNKNOWN:
+						throw new RuntimeException("Unknown IPTC record"); 
+				}
+		}
+		
+		return tagEnum.getName();
+	}
+	
+	@Override
+	public int hashCode() {
+		final int prime = 31;
+		int result = 1;
+		result = prime * result + Arrays.hashCode(ArrayUtils.subArray(data, offset, size));
+		result = prime * result + recordNumber;
+		result = prime * result + tag;
+		return result;
+	}
+	
+	/**
+	 * Write the current IPTCDataSet to the OutputStream
+	 * 
+	 * @param out OutputStream to write the IPTCDataSet
+	 * @throws IOException
+	 */
+	public void write(OutputStream out) throws IOException {
+		out.write(0x1c); // tag marker
+		out.write(recordNumber);
+		out.write(getTag());
+		IOUtils.writeShortMM(out, size);
+		out.write(data, offset, size);
+	}
+}
diff --git a/src/pixy/meta/iptc/IPTCEnvelopeTag.java b/src/pixy/meta/iptc/IPTCEnvelopeTag.java
new file mode 100644
index 0000000..18c5872
--- /dev/null
+++ b/src/pixy/meta/iptc/IPTCEnvelopeTag.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.iptc;
+
+import java.io.UnsupportedEncodingException;
+import java.util.HashMap;
+import java.util.Map;
+
+import pixy.meta.iptc.IPTCEnvelopeTag;
+import pixy.meta.iptc.IPTCTag;
+import pixy.string.StringUtils;
+
+/**
+ * Defines DataSet tags for IPTC Envelope Record - Record number 1.
+ * * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2015
+ */
+public enum IPTCEnvelopeTag implements IPTCTag {
+	 RECORD_VERSION(0, "EnvelopeRecordVersion"),
+	 DESTINATION(5, "Destination") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 FILE_FORMAT(20, "FileFormat"),
+	 FILE_VERSION(22, "FileVersion"),
+	 SERVICE_IDENTIFIER(30, "ServiceIdentifier"),
+	 ENVELOPE_NUMBER(40, "EnvelopeNumber"),
+	 PRODUCT_ID(50, "ProductID") {
+		 @Override
+		 public boolean allowMultiple() {
+			 return true;
+		 }
+	 },
+	 ENVELOPE_PRIORITY(60, "EnvelopePriority"),
+	 DATE_SENT(70, "DateSent"),
+	 TIME_SENT(80, "TimeSent"),
+	 CODED_CHARACTER_SET(90, "CodedCharacterSet"),
+	 UNIQUE_OBJECT_NAME(100, "UniqueObjectName"),
+	 ARM_IDENTIFIER(120, "ARMIdentifier"),
+	 ARM_VERSION(122, "ARMVersion"),
+	 	 
+	 UNKNOWN(999, "Unknown");
+	 
+	 private IPTCEnvelopeTag(int tag, String name) {
+		 this.tag = tag;
+		 this.name = name;
+	 }
+	 
+	 public boolean allowMultiple() {
+		 return false;
+	 }
+	 
+	 // Default implementation. Could be replaced by individual ENUM
+	 public String getDataAsString(byte[] data) {
+		 try {
+			 String strVal = new String(data, "UTF-8").trim();
+			 if(strVal.length() > 0) return strVal;
+		 } catch (UnsupportedEncodingException e) {
+			 e.printStackTrace();
+		 }
+		 // Hex representation of the data
+		 return StringUtils.byteArrayToHexString(data, 0, IPTCTag.MAX_STRING_REPR_LEN);
+	 }
+	 
+	 public String getName() {
+		 return name;
+	 }
+	 
+	 public int getRecordNumber() {
+		 return IPTCRecord.ENVELOP.getRecordNumber();
+	 }
+	 
+	 public int getTag() { return tag; }
+	 
+	 public static IPTCEnvelopeTag fromTag(int value) {
+      	IPTCEnvelopeTag record = recordMap.get(value);
+	   	if (record == null)
+	   		return UNKNOWN;
+   		return record;
+	 }
+  
+	 @Override public String toString() {
+	   return name;
+	 }
+  
+	 private static final Map<Integer, IPTCEnvelopeTag> recordMap = new HashMap<Integer, IPTCEnvelopeTag>();
+   
+	 static
+	 {
+		 for(IPTCEnvelopeTag record : values()) {
+			 recordMap.put(record.getTag(), record);
+		 }
+	 }	    
+ 
+	 private final int tag;
+	 private final String name;
+}
diff --git a/src/pixy/meta/iptc/IPTCFotoStationTag.java b/src/pixy/meta/iptc/IPTCFotoStationTag.java
new file mode 100644
index 0000000..d7d7823
--- /dev/null
+++ b/src/pixy/meta/iptc/IPTCFotoStationTag.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.iptc;
+
+import java.io.UnsupportedEncodingException;
+import java.util.HashMap;
+import java.util.Map;
+
+import pixy.meta.iptc.IPTCFotoStationTag;
+import pixy.meta.iptc.IPTCTag;
+import pixy.string.StringUtils;
+
+/**
+ * Defines DataSet tags for IPTC FotoStation Record - Record number 240.
+ * * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2015
+ */
+public enum IPTCFotoStationTag implements IPTCTag  {
+	// No record available
+	UNKNOWN(999, "Unknown");
+	 
+	private IPTCFotoStationTag(int tag, String name) {
+		this.tag = tag;
+		this.name = name;
+	}
+	 
+	public static IPTCFotoStationTag fromTag(int value) {
+		IPTCFotoStationTag record = recordMap.get(value);
+	   	if (record == null)
+	   		return UNKNOWN;
+		return record;
+	}
+	
+	 public boolean allowMultiple() {
+		 return false;
+	 }
+	 
+	 // Default implementation. Could be replaced by individual ENUM
+	 public String getDataAsString(byte[] data) {
+		 try {
+			 String strVal = new String(data, "UTF-8").trim();
+			 if(strVal.length() > 0) return strVal;
+		 } catch (UnsupportedEncodingException e) {
+			 e.printStackTrace();
+		 }
+		 // Hex representation of the data
+		 return StringUtils.byteArrayToHexString(data, 0, IPTCTag.MAX_STRING_REPR_LEN);
+	 }
+	
+	public String getName() {
+		return name;
+	}
+	
+	public int getRecordNumber() {
+		return IPTCRecord.FOTOSTATION.getRecordNumber();
+	}
+	
+	public int getTag() {
+		return tag;
+	}	
+ 
+	@Override public String toString() {
+		return name;
+	}
+	 
+	private static final Map<Integer, IPTCFotoStationTag> recordMap = new HashMap<Integer, IPTCFotoStationTag>();
+	  
+	static
+	{
+	    for(IPTCFotoStationTag record : values()) {
+	        recordMap.put(record.getTag(), record);
+	    }
+	}	    
+	
+	private final int tag;
+	private final String name;
+}
diff --git a/src/pixy/meta/iptc/IPTCNewsPhotoTag.java b/src/pixy/meta/iptc/IPTCNewsPhotoTag.java
new file mode 100644
index 0000000..698dc72
--- /dev/null
+++ b/src/pixy/meta/iptc/IPTCNewsPhotoTag.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.iptc;
+
+import java.io.UnsupportedEncodingException;
+import java.util.HashMap;
+import java.util.Map;
+
+import pixy.meta.iptc.IPTCNewsPhotoTag;
+import pixy.meta.iptc.IPTCTag;
+import pixy.string.StringUtils;
+
+/**
+ * Defines DataSet tags for IPTC NewsPhoto Record - Record number 3.
+ * * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2015
+ */
+public enum IPTCNewsPhotoTag implements IPTCTag {
+	 RECORD_VERSION(0, "NewsPhotoVersion"),
+	 PICTURE_NUMBER(10, "PictureNumber"),
+	 IMAGE_WIDTH(20, "ImageWidth"),
+	 IMAGE_HEIGHT(30, "ImageHeight"),
+	 PIXEL_WIDTH(40, "PixelWidth"),
+	 PIXEL_HEIGHT(50, "PixelHeight"),
+	 SUPPLEMENTAL_TYPE(55, "SupplementalType"),
+	 COLOR_REPRESENTATION(60, "ColorRepresentation"),
+	 INTERCHANGE_COLOR_SPACE(64, "InterchangeColorSpace"),
+	 COLOR_SEQUENCE(65, "ColorSequence"),
+	 ICC_PROFILE(66, "ICC_Profile"),
+	 COLOR_CALIBRATION_MATRIX(70, "ColorCalibrationMatrix"),
+	 LOOKUP_TABLE(80, "LookupTable"),
+	 NUM_INDEX_ENTRIES(84, "NumIndexEntries"),
+	 COLOR_PALETTE(85, "ColorPalette"),
+	 BITS_PER_SAMPLE(86, "BitsPerSample"),
+	 SAMPLE_STRUCTURE(90, "SampleStructure"),
+	 SCANNING_DIRECTION(100, "ScanningDirection"),
+	 IMAGE_ROTATION(102, "ImageRotation"),
+	 DATA_COMPRESSION_METHOD(110, "DataCompressionMethod"),
+	 QUANTIZATION_METHOD(120, "QuantizationMethod"),
+	 END_POINTS(125, "EndPoints"),
+	 EXCURSION_TOLERANCE(130, "ExcursionTolerance"),
+	 BITS_PER_COMPONENT(135, "BitsPerComponent"),
+	 MAXIMUM_DENSITY_RANGE(140, "MaximumDensityRange"),
+	 GAMMA_COMPENSATED_VALUE(145, "GammaCompensatedValue"),
+	 	 
+	 UNKNOWN(999, "Unknown");
+	 
+	 private IPTCNewsPhotoTag(int tag, String name) {
+		 this.tag = tag;
+		 this.name = name;
+	 }
+	 
+	 public boolean allowMultiple() {
+		 return false;
+	 }
+	 
+	 // Default implementation. Could be replaced by individual ENUM
+	 public String getDataAsString(byte[] data) {
+		 try {
+			 String strVal = new String(data, "UTF-8").trim();
+			 if(strVal.length() > 0) return strVal;
+		 } catch (UnsupportedEncodingException e) {
+			 e.printStackTrace();
+		 }
+		 // Hex representation of the data
+		 return StringUtils.byteArrayToHexString(data, 0, IPTCTag.MAX_STRING_REPR_LEN);
+	 }
+	 
+	 public String getName() {
+		 return name;
+	 }
+	 
+	 public int getRecordNumber() {
+		 return IPTCRecord.NEWSPHOTO.getRecordNumber();
+	 }
+	 
+	 public int getTag() {
+		 return tag;
+	 }
+	 
+	 public static IPTCNewsPhotoTag fromTag(int value) {
+      	IPTCNewsPhotoTag record = recordMap.get(value);
+	   	if (record == null)
+	   		return UNKNOWN;
+	 	return record;
+	 }
+  
+	 @Override public String toString() {
+		   return name;
+	 }
+  
+	 private static final Map<Integer, IPTCNewsPhotoTag> recordMap = new HashMap<Integer, IPTCNewsPhotoTag>();
+   
+	 static
+	 {
+		 for(IPTCNewsPhotoTag record : values()) {
+			 recordMap.put(record.getTag(), record);
+		 }
+	 }	    
+   
+	 private final int tag;
+	 private final String name;
+}
diff --git a/src/pixy/meta/iptc/IPTCObjectDataTag.java b/src/pixy/meta/iptc/IPTCObjectDataTag.java
new file mode 100644
index 0000000..673d305
--- /dev/null
+++ b/src/pixy/meta/iptc/IPTCObjectDataTag.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.iptc;
+
+import java.io.UnsupportedEncodingException;
+import java.util.HashMap;
+import java.util.Map;
+
+import pixy.meta.iptc.IPTCObjectDataTag;
+import pixy.meta.iptc.IPTCTag;
+import pixy.string.StringUtils;
+
+/**
+ * Defines DataSet tags for IPTC ObjectData Record - Record number 8.
+ * * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2015
+ */
+public enum IPTCObjectDataTag implements IPTCTag {
+	 SUB_FILE(10, "SubFile"),
+		 	 	 
+	 UNKNOWN(999, "Unknown");
+	 
+	 private IPTCObjectDataTag(int tag, String name) {
+		 this.tag = tag;
+		 this.name = name;
+	 }
+	 
+	 public static IPTCObjectDataTag fromTag(int value) {
+		 IPTCObjectDataTag record = recordMap.get(value);
+	   	if (record == null)
+	   		return UNKNOWN;
+      	return record;
+	 }
+	 
+	 public boolean allowMultiple() {
+		 return false;
+	 }
+	 
+	 // Default implementation. Could be replaced by individual ENUM
+	 public String getDataAsString(byte[] data) {
+		 try {
+			 String strVal = new String(data, "UTF-8").trim();
+			 if(strVal.length() > 0) return strVal;
+		 } catch (UnsupportedEncodingException e) {
+			 e.printStackTrace();
+		 }
+		 // Hex representation of the data
+		 return StringUtils.byteArrayToHexString(data, 0, IPTCTag.MAX_STRING_REPR_LEN);
+	 }
+	 
+	 public String getName() {
+		 return name;
+	 }
+	 
+	 public int getRecordNumber() {
+		 return IPTCRecord.OBJECTDATA.getRecordNumber();
+	 }
+	 
+	 public int getTag() {
+		 return tag;
+	 }
+  
+	 @Override public String toString() {
+	   return name;
+	 }
+  
+	 private static final Map<Integer, IPTCObjectDataTag> recordMap = new HashMap<Integer, IPTCObjectDataTag>();
+   
+	 static
+	 {
+		 for(IPTCObjectDataTag record : values()) {
+			 recordMap.put(record.getTag(), record);
+		 }
+	 }	    
+ 
+	 private final int tag;
+	 private final String name;
+}
diff --git a/src/pixy/meta/iptc/IPTCPostObjectDataTag.java b/src/pixy/meta/iptc/IPTCPostObjectDataTag.java
new file mode 100644
index 0000000..f164984
--- /dev/null
+++ b/src/pixy/meta/iptc/IPTCPostObjectDataTag.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.iptc;
+
+import java.io.UnsupportedEncodingException;
+import java.util.HashMap;
+import java.util.Map;
+
+import pixy.meta.iptc.IPTCPostObjectDataTag;
+import pixy.meta.iptc.IPTCTag;
+import pixy.string.StringUtils;
+
+/**
+ * Defines DataSet tags for IPTC PostObjectData Record - Record number 9.
+ * * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2015
+ */
+public enum IPTCPostObjectDataTag implements IPTCTag {
+	 CONFIRMED_OBJECT_SIZE(10, "ConfirmedObjectSize"),
+		 	 	 
+	 UNKNOWN(999, "Unknown");
+	 
+	 private IPTCPostObjectDataTag(int tag, String name) {
+		 this.tag = tag;
+		 this.name = name;
+	 }
+	 
+	 public boolean allowMultiple() {
+		 return false;
+	 }
+	 
+	 // Default implementation. Could be replaced by individual ENUM
+	 public String getDataAsString(byte[] data) {
+		 try {
+			 String strVal = new String(data, "UTF-8").trim();
+			 if(strVal.length() > 0) return strVal;
+		 } catch (UnsupportedEncodingException e) {
+			 e.printStackTrace();
+		 }
+		 // Hex representation of the data
+		 return StringUtils.byteArrayToHexString(data, 0, IPTCTag.MAX_STRING_REPR_LEN);
+	 }
+	 
+	 public String getName() {
+		return name; 
+	 }
+	 
+	 public int getRecordNumber() {
+	 	return IPTCRecord.POST_OBJECTDATA.getRecordNumber();
+	 }
+	 
+	 public int getTag() {
+		 return tag;
+	 }
+	 
+	 public static IPTCPostObjectDataTag fromTag(int value) {
+		 IPTCPostObjectDataTag record = recordMap.get(value);
+	   	if (record == null)
+	   		return UNKNOWN;
+      	return record;
+	 }
+  
+	 @Override public String toString() {
+	   return name;
+	 }
+  
+	 private static final Map<Integer, IPTCPostObjectDataTag> recordMap = new HashMap<Integer, IPTCPostObjectDataTag>();
+   
+	 static
+	 {
+		 for(IPTCPostObjectDataTag record : values()) {
+			 recordMap.put(record.getTag(), record);
+		 }
+	 }	    
+ 
+	 private final int tag;
+	 private final String name;
+}
diff --git a/src/pixy/meta/iptc/IPTCPreObjectDataTag.java b/src/pixy/meta/iptc/IPTCPreObjectDataTag.java
new file mode 100644
index 0000000..19bb55e
--- /dev/null
+++ b/src/pixy/meta/iptc/IPTCPreObjectDataTag.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.iptc;
+
+import java.io.UnsupportedEncodingException;
+import java.util.HashMap;
+import java.util.Map;
+
+import pixy.meta.iptc.IPTCPreObjectDataTag;
+import pixy.meta.iptc.IPTCTag;
+import pixy.string.StringUtils;
+
+/**
+ * Defines DataSet tags for IPTC PreObjectData Record - Record number 7.
+ * * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2015
+ */
+public enum IPTCPreObjectDataTag implements IPTCTag {
+	 SIZE_MODE(10, "SizeMode"),
+	 MAX_SUBFILE_SIZE(20, "MaxSubfileSize"),
+	 OBJECT_SIZE_ANNOUNCED(90, "ObjectSizeAnnounced"),
+	 MAXIMUM_OBJECT_SIZE(95, "MaximumObjectSize"),
+	 	 	 
+	 UNKNOWN(999, "Unknown");
+	 
+	 private IPTCPreObjectDataTag(int tag, String name) {
+		 this.tag = tag;
+		 this.name = name;
+	 }
+	 
+	 public boolean allowMultiple() {
+		 return false;
+	 }
+	 
+	 // Default implementation. Could be replaced by individual ENUM
+	 public String getDataAsString(byte[] data) {
+		 try {
+			 String strVal = new String(data, "UTF-8").trim();
+			 if(strVal.length() > 0) return strVal;
+		 } catch (UnsupportedEncodingException e) {
+			 e.printStackTrace();
+		 }
+		 // Hex representation of the data
+		 return StringUtils.byteArrayToHexString(data, 0, IPTCTag.MAX_STRING_REPR_LEN);
+	 }
+	 
+	 public String getName() {
+		 return name;
+	 }
+	 
+	 public int getRecordNumber() {
+	 	return IPTCRecord.PRE_OBJECTDATA.getRecordNumber();
+	 }
+	 
+	 public int getTag() {
+		 return tag;
+	 }
+	 
+	 public static IPTCPreObjectDataTag fromTag(int value) {
+		 IPTCPreObjectDataTag record = recordMap.get(value);
+	   	if (record == null)
+	   		return UNKNOWN;
+     	return record;
+	 }
+  
+	 @Override public String toString() {
+	   return name;
+	 }
+  
+	 private static final Map<Integer, IPTCPreObjectDataTag> recordMap = new HashMap<Integer, IPTCPreObjectDataTag>();
+   
+	 static
+	 {
+		 for(IPTCPreObjectDataTag record : values()) {
+			 recordMap.put(record.getTag(), record);
+		 }
+	 }	    
+ 
+	 private final int tag;
+	 private final String name;
+}
diff --git a/src/pixy/meta/iptc/IPTCRecord.java b/src/pixy/meta/iptc/IPTCRecord.java
new file mode 100644
index 0000000..4798eed
--- /dev/null
+++ b/src/pixy/meta/iptc/IPTCRecord.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.iptc;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import pixy.meta.iptc.IPTCRecord;
+
+/**
+ * Defines IPTC data set record number
+ * * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 03/13/2015
+ */
+public enum IPTCRecord {
+	ENVELOP(1, "Envelop"), APPLICATION(2, "Application"), NEWSPHOTO(3, "NewsPhoto"),
+	PRE_OBJECTDATA(7, "PreObjectData"), OBJECTDATA(8, "ObjectData"), POST_OBJECTDATA(9, "PostObjectData"),
+	FOTOSTATION(240, "FotoStation"), UNKNOWN(999, "Unknown");	
+	
+	private IPTCRecord(int recordNumber, String name) {
+		this.recordNumber = recordNumber;
+		this.name = name;
+	}
+	
+	public String getName() {
+		return name;
+	}
+	
+	public int getRecordNumber() {
+		return recordNumber;
+	}
+	
+	@Override public String toString() {
+		return name;
+	}
+	
+	public static IPTCRecord fromRecordNumber(int value) {
+      	IPTCRecord record = recordMap.get(value);
+	   	if (record == null)
+	   		return UNKNOWN;
+   		return record;
+	}
+	
+	private static final Map<Integer, IPTCRecord> recordMap = new HashMap<Integer, IPTCRecord>();
+	   
+	static
+	{
+		for(IPTCRecord record : values()) {
+			recordMap.put(record.getRecordNumber(), record);
+		}
+	}	    
+ 	
+	private final int recordNumber;
+	private final String name;
+}
diff --git a/src/pixy/meta/iptc/IPTCTag.java b/src/pixy/meta/iptc/IPTCTag.java
new file mode 100644
index 0000000..769736d
--- /dev/null
+++ b/src/pixy/meta/iptc/IPTCTag.java
@@ -0,0 +1,11 @@
+package pixy.meta.iptc;
+
+public interface IPTCTag {
+	public int getRecordNumber();
+	public int getTag();
+	public String getName();
+	public boolean allowMultiple();
+	public String getDataAsString(byte[] data);
+	
+	public static final int MAX_STRING_REPR_LEN = 10;
+}
diff --git a/src/pixy/meta/iptc/IPTCTagComparator.java b/src/pixy/meta/iptc/IPTCTagComparator.java
new file mode 100644
index 0000000..f5021c3
--- /dev/null
+++ b/src/pixy/meta/iptc/IPTCTagComparator.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.iptc;
+
+import java.util.Comparator;
+
+public class IPTCTagComparator implements Comparator<IPTCTag> {
+
+	public int compare(IPTCTag o1, IPTCTag o2) {	
+		final int BEFORE = -1;
+	    final int EQUAL = 0;
+	    final int AFTER = 1;
+	    
+	    if(o1 == o2) return EQUAL;
+	    
+	    if (o1.getRecordNumber() < o2.getRecordNumber()) return BEFORE;
+	    if (o1.getRecordNumber() > o2.getRecordNumber()) return AFTER;
+	    if(o1.getRecordNumber() == o2.getRecordNumber()) {
+	    	if (o1.getTag() < o2.getTag()) return BEFORE;
+		    if (o1.getTag() > o2.getTag()) return AFTER;
+		    return EQUAL;
+	    }
+	
+		return EQUAL;
+	}
+}
diff --git a/src/pixy/meta/jpeg/Adobe.java b/src/pixy/meta/jpeg/Adobe.java
new file mode 100644
index 0000000..800d77a
--- /dev/null
+++ b/src/pixy/meta/jpeg/Adobe.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * Adobe.java
+ *
+ * Who   Date       Description
+ * ====  =======    ============================================================
+ * WY    02Jul2015  Initial creation
+ */
+
+package pixy.meta.jpeg;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import pixy.meta.Metadata;
+import pixy.meta.MetadataEntry;
+import pixy.meta.MetadataType;
+import pixy.string.StringUtils;
+import pixy.io.IOUtils;
+
+public class Adobe extends Metadata {
+
+	private int m_DCTEncodeVersion;
+	private int m_APP14Flags0;
+	private int m_APP14Flags1;
+	private int m_ColorTransform;
+	
+	public Adobe(byte[] data) {
+		super(MetadataType.JPG_ADOBE, data);
+		ensureDataRead();
+	}
+	
+	public Adobe(int dctEncodeVersion, int app14Flags0, int app14Flags1, int colorTransform) {
+		super(MetadataType.JPG_ADOBE);
+		this.m_DCTEncodeVersion = dctEncodeVersion;
+		this.m_APP14Flags0 = app14Flags0;
+		this.m_APP14Flags1 = app14Flags1;
+		this.m_ColorTransform = colorTransform;
+		isDataRead = true;
+	}
+	
+	public int getAPP14Flags0() {
+		return m_APP14Flags0;
+	}
+	
+	public int getAPP14Flags1() {
+		return m_APP14Flags1;
+	}
+	
+	public int getColorTransform() {
+		return m_ColorTransform;
+	}
+	
+	public int getDCTEncodeVersion() {
+		return m_DCTEncodeVersion;
+	}
+	
+	public void read() throws IOException {
+		if(!isDataRead) {
+			int expectedLen = 7;
+			int offset = 0;
+			
+			if (data.length >= expectedLen) {
+				m_DCTEncodeVersion = IOUtils.readUnsignedShortMM(data, offset);
+				offset += 2;
+				m_APP14Flags0 = IOUtils.readUnsignedShortMM(data, offset);
+				offset += 2;
+				m_APP14Flags1 = IOUtils.readUnsignedShortMM(data, offset);
+				offset += 2;
+				m_ColorTransform = data[offset]&0xff;			
+			}
+			
+		    isDataRead = true;
+		}
+	}
+
+	public Iterator<MetadataEntry> iterator() {
+		ensureDataRead();
+		
+		List<MetadataEntry> entries = new ArrayList<MetadataEntry>();
+		String[] colorTransform = {"Unknown (RGB or CMYK)", "YCbCr", "YCCK"};
+		entries.add(new MetadataEntry("DCTEncodeVersion", m_DCTEncodeVersion + ""));
+		entries.add(new MetadataEntry("APP14Flags0", StringUtils.shortToHexStringMM((short)m_APP14Flags0)));
+		entries.add(new MetadataEntry("APP14Flags1", StringUtils.shortToHexStringMM((short)m_APP14Flags1)));
+		entries.add(new MetadataEntry("ColorTransform", (m_ColorTransform <= 2)?colorTransform[m_ColorTransform]:m_ColorTransform + ""));
+		
+		return Collections.unmodifiableCollection(entries).iterator();
+	}
+
+	public void write(OutputStream os) throws IOException {
+		ensureDataRead();
+		IOUtils.writeShortMM(os, getDCTEncodeVersion());
+		IOUtils.writeShortMM(os, getAPP14Flags0());
+		IOUtils.writeShortMM(os, getAPP14Flags1());
+		IOUtils.write(os, getColorTransform());
+	}
+}
diff --git a/src/pixy/meta/jpeg/Ducky.java b/src/pixy/meta/jpeg/Ducky.java
new file mode 100644
index 0000000..f09af6f
--- /dev/null
+++ b/src/pixy/meta/jpeg/Ducky.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * Ducky.java
+ *
+ * Who   Date       Description
+ * ====  =======    ============================================================
+ * WY    02Jul2015  Initial creation
+ */
+
+package pixy.meta.jpeg;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.io.IOUtils;
+import pixy.meta.Metadata;
+import pixy.meta.MetadataEntry;
+import pixy.meta.MetadataType;
+
+public class Ducky extends Metadata {
+
+	private Map<DuckyTag, DuckyDataSet> datasetMap;
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(Ducky.class);
+		
+	public Ducky() {
+		super(MetadataType.JPG_DUCKY);
+		datasetMap =  new EnumMap<DuckyTag, DuckyDataSet>(DuckyTag.class);
+		isDataRead = true;
+	}
+	
+	public Ducky(byte[] data) {
+		super(MetadataType.JPG_DUCKY, data);
+	}
+	
+	public void addDataSet(DuckyDataSet dataSet) {
+		if(datasetMap != null) {
+			datasetMap.put(DuckyTag.fromTag(dataSet.getTag()), dataSet);				
+		}
+	}
+	
+	public void addDataSets(Collection<? extends DuckyDataSet> dataSets) {
+		if(datasetMap != null) {
+			for(DuckyDataSet dataSet: dataSets) {
+				datasetMap.put(DuckyTag.fromTag(dataSet.getTag()), dataSet);				
+			}
+		}
+	}
+	
+	public Map<DuckyTag, DuckyDataSet> getDataSets() {
+		ensureDataRead();
+		return Collections.unmodifiableMap(datasetMap);
+	}
+	
+	public Iterator<MetadataEntry> iterator() {
+		ensureDataRead();
+		
+		List<MetadataEntry> entries = new ArrayList<MetadataEntry>();
+			
+		for(DuckyDataSet dataset : datasetMap.values()) {
+			entries.add(dataset.getMetadataEntry());
+		}
+		
+		return Collections.unmodifiableCollection(entries).iterator();
+	}
+	
+	public void read() throws IOException {
+		if(!isDataRead) {
+			int i = 0;
+			datasetMap = new EnumMap<DuckyTag, DuckyDataSet>(DuckyTag.class);
+			
+			for(;;) {
+				if(i + 4 > data.length) break;
+				int tag = IOUtils.readUnsignedShortMM(data, i);
+				i += 2;
+				int size = IOUtils.readUnsignedShortMM(data, i);
+				i += 2;
+				DuckyTag etag = DuckyTag.fromTag(tag);
+				datasetMap.put(etag, new DuckyDataSet(tag, size, data, i));
+				i += size;
+			}
+			
+		    isDataRead = true;
+		}
+	}
+
+	public void showMetadata() {
+		ensureDataRead();
+		LOGGER.info("JPEG Ducky output starts =>");
+		// Print DuckyDataSet
+		for(DuckyDataSet dataset : datasetMap.values()) {
+			dataset.print();
+		}
+		LOGGER.info("<= JPEG Ducky output ends");
+	}
+	
+	public void write(OutputStream os) throws IOException {
+		ensureDataRead();
+		for(DuckyDataSet dataset : getDataSets().values())
+			dataset.write(os);
+	}
+}
diff --git a/src/pixy/meta/jpeg/DuckyDataSet.java b/src/pixy/meta/jpeg/DuckyDataSet.java
new file mode 100644
index 0000000..4fb7fbe
--- /dev/null
+++ b/src/pixy/meta/jpeg/DuckyDataSet.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * DuckyDataSet.java
+ *
+ * Who   Date       Description
+ * ====  =======    ============================================================
+ * WY    02Jul2015  Initial creation
+ */
+
+package pixy.meta.jpeg;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.io.IOUtils;
+import pixy.meta.MetadataEntry;
+import pixy.util.ArrayUtils;
+
+public class DuckyDataSet {
+	private int tag;
+	private int size;
+	private byte[] data;
+	private int offset;
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(DuckyDataSet.class);
+	
+	public DuckyDataSet(int tag, byte[] data) {
+		this(tag, data.length, data, 0);
+	}
+	
+	public DuckyDataSet(int tag, int size, byte[] data, int offset) {
+		this.tag = tag;
+		this.size = size;
+		this.data = data;
+		this.offset = offset;
+	}
+	
+	public byte[] getData() {
+		return ArrayUtils.subArray(data, offset, size);
+	}
+	
+	public MetadataEntry getMetadataEntry() {
+		//
+		MetadataEntry entry = null;
+		
+		if(size < 4) {
+			LOGGER.warn("Data set size {} is too small, should >= 4", size);
+			return new MetadataEntry("Bad Ducky DataSet", "Data set size " + size + " is two small, should >= 4");
+		}
+			
+		DuckyTag etag = DuckyTag.fromTag(tag);
+		
+		if(etag == DuckyTag.UNKNOWN) {
+			entry = new MetadataEntry("Unknown tag", tag + "");
+		} else if(etag == DuckyTag.QUALITY) {
+			entry = new MetadataEntry(etag.getName(), IOUtils.readUnsignedIntMM(data, offset) + "");
+		} else {
+			String value = "";
+			try {
+				// We need to skip 4 unknown bytes for each string entry!!!
+				value = new String(data, offset + 4, size - 4, "UTF-16BE");
+			} catch (UnsupportedEncodingException e) {
+				LOGGER.error("UnsupportedEncoding \"UTF-16BE\"");
+			}
+			entry = new MetadataEntry(etag.getName(), value);
+		}
+		
+		return entry;
+	}
+	
+	public int getSize() {
+		return size;
+	}
+	
+	public int getTag() {
+		return tag;
+	}
+	
+	public void print() {
+		if(size < 4) {
+			LOGGER.warn("Data set size {} is too small, should >= 4", size);
+			return;
+		}
+			
+		DuckyTag etag = DuckyTag.fromTag(tag);
+		
+		if(etag == DuckyTag.UNKNOWN) {
+			LOGGER.info("Unknown tag: {}", tag);
+		} else if(etag == DuckyTag.QUALITY) {
+			LOGGER.info(etag + ": {}", IOUtils.readUnsignedIntMM(data, offset));
+		} else {
+			String value = "";
+			try {
+				// We need to skip 4 unknown bytes for each string entry!!!
+				value = new String(data, offset + 4, size - 4, "UTF-16BE");
+			} catch (UnsupportedEncodingException e) {
+				LOGGER.error("UnsupportedEncoding \"UTF-16BE\"");
+			}
+			LOGGER.info(etag + ": {}", value);
+		}
+	}
+	
+	public void write(OutputStream out) throws IOException {
+		IOUtils.writeShortMM(out, tag);
+		IOUtils.writeShortMM(out, size);
+		out.write(data, offset, size);
+	}
+}
diff --git a/src/pixy/meta/jpeg/DuckyTag.java b/src/pixy/meta/jpeg/DuckyTag.java
new file mode 100644
index 0000000..b0c190c
--- /dev/null
+++ b/src/pixy/meta/jpeg/DuckyTag.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * DuckyTag.java
+ *
+ * Who   Date       Description
+ * ====  =======    ============================================================
+ * WY    02Jul2015  Initial creation
+ */
+
+package pixy.meta.jpeg;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public enum DuckyTag {
+	//
+	QUALITY(1, "Quality"),
+	COMMENT(2, "Comment"),
+	COPYRIGHT(3, "Copyright"),
+	 
+	UNKNOWN(999, "Unknown");
+	 
+	private static final Map<Integer, DuckyTag> recordMap = new HashMap<Integer, DuckyTag>();
+	 
+	static {
+		for(DuckyTag record : values()) {
+			recordMap.put(record.getTag(), record);
+		}
+	}
+	 
+	public static DuckyTag fromTag(int value) {
+		 DuckyTag record = recordMap.get(value);
+		 if (record == null)
+			 return UNKNOWN;
+		 return record;
+ 	}
+	 
+	private final int tag;
+   
+	private final String name;
+   
+	private DuckyTag(int tag, String name) {
+		this.tag = tag;
+		this.name = name;
+	}
+    
+	public String getName() {
+		return name;
+	}	    
+  
+	public int getTag() { 
+		return tag;
+	}
+	 
+	@Override public String toString() {
+		return name;
+	}	
+}
diff --git a/src/pixy/meta/jpeg/JFIF.java b/src/pixy/meta/jpeg/JFIF.java
new file mode 100644
index 0000000..1ce290d
--- /dev/null
+++ b/src/pixy/meta/jpeg/JFIF.java
@@ -0,0 +1,187 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * JFIF.java
+ *
+ * Who   Date       Description
+ * ====  =======    ============================================================
+ * WY    12Jul2015  Initial creation
+ */
+
+package pixy.meta.jpeg;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import android.graphics.Bitmap;
+import pixy.meta.Metadata;
+import pixy.meta.MetadataEntry;
+import pixy.meta.MetadataType;
+import pixy.io.IOUtils;
+import pixy.util.ArrayUtils;
+import pixy.util.MetadataUtils;
+
+public class JFIF extends Metadata {
+		
+	private static void checkInput(int majorVersion, int minorVersion, int densityUnit, int xDensity, int yDensity) {
+		if(majorVersion < 0 || majorVersion > 0xff) throw new IllegalArgumentException("Invalid major version number: " + majorVersion);
+		if(minorVersion < 0 || minorVersion > 0xff) throw new IllegalArgumentException("Invalid minor version number: " + minorVersion);
+		if(densityUnit < 0 || densityUnit > 2) throw new IllegalArgumentException("Density unit value " + densityUnit + " out of range [0-2]");
+		if(xDensity < 0 || xDensity > 0xffff) throw new IllegalArgumentException("xDensity value " + xDensity + " out of range (0-0xffff]");
+		if(yDensity < 0 || yDensity > 0xffff) throw new IllegalArgumentException("yDensity value " + xDensity + " out of range (0-0xffff]");
+	}
+	
+	private int majorVersion;
+	private int minorVersion;
+	private int densityUnit;
+	private int xDensity;
+	private int yDensity;
+	private int thumbnailWidth;
+	private int thumbnailHeight;
+	private boolean containsThumbnail;
+	
+	private JFIFThumbnail thumbnail;
+
+	public JFIF(byte[] data) {
+		super(MetadataType.JPG_JFIF, data);
+		ensureDataRead();
+	}
+	
+	public JFIF(int majorVersion, int minorVersion, int densityUnit, int xDensity, int yDensity) {
+		this(majorVersion, minorVersion, densityUnit, xDensity, yDensity, null);
+	}
+	
+	public JFIF(int majorVersion, int minorVersion, int densityUnit, int xDensity, int yDensity, JFIFThumbnail thumbnail) {
+		super(MetadataType.JPG_JFIF);
+		checkInput(majorVersion, minorVersion, densityUnit, xDensity, yDensity);
+		this.majorVersion = majorVersion;
+		this.minorVersion = minorVersion;
+		this.densityUnit = densityUnit;
+		this.xDensity = xDensity;
+		this.yDensity = yDensity;
+		
+		if(thumbnail != null) {
+			int thumbnailWidth = thumbnail.getWidth();
+			int thumbnailHeight = thumbnail.getHeight();
+			if(thumbnailWidth < 0 || thumbnailWidth > 0xff)
+				throw new IllegalArgumentException("Thumbnail width " + thumbnailWidth + " out of range (0-0xff]");
+			if(thumbnailHeight < 0 || thumbnailHeight > 0xff)
+				throw new IllegalArgumentException("Thumbnail height " + thumbnailHeight + " out of range (0-0xff]");
+			this.thumbnailWidth = thumbnailWidth;
+			this.thumbnailHeight = thumbnailHeight;
+			this.thumbnail = thumbnail;
+			this.containsThumbnail = true;
+		}
+		
+		isDataRead = true;
+	}
+	
+	public boolean containsThumbnail() {
+		return containsThumbnail;
+	}
+	
+	public int getDensityUnit() {
+		return densityUnit;
+	}
+	
+	public int getMajorVersion() {
+		return majorVersion;
+	}
+	
+	public int getMinorVersion() {
+		return minorVersion;
+	}
+	
+	public JFIFThumbnail getThumbnail() {
+		return new JFIFThumbnail(thumbnail);
+	}
+	
+	public int getThumbnailHeight() {
+		return thumbnailHeight;
+	}
+	
+	public int getThumbnailWidth() {
+		return thumbnailWidth;
+	}
+
+	public int getXDensity() {
+		return xDensity;
+	}
+	
+	public int getYDensity() {
+		return yDensity;
+	}
+	
+	public Iterator<MetadataEntry> iterator() {
+		ensureDataRead();
+		List<MetadataEntry> entries = new ArrayList<MetadataEntry>();
+		String[] densityUnits = {"No units, aspect ratio only specified", "Dots per inch", "Dots per centimeter"};
+		entries.add(new MetadataEntry("Version", majorVersion + "." + minorVersion));
+		entries.add(new MetadataEntry("Density unit", (densityUnit <= 2)?densityUnits[densityUnit]:densityUnit + ""));
+		entries.add(new MetadataEntry("XDensity", xDensity + ""));
+		entries.add(new MetadataEntry("YDensity", yDensity + ""));
+		entries.add(new MetadataEntry("Thumbnail width", thumbnailWidth + ""));
+		entries.add(new MetadataEntry("Thumbnail height", thumbnailHeight + ""));
+		
+		return Collections.unmodifiableCollection(entries).iterator();
+	}
+	
+	public void read() throws IOException {
+		if(!isDataRead) {
+			int expectedLen = 9;
+			int offset = 0;
+			
+			if (data.length >= expectedLen) {
+				majorVersion = data[offset++]&0xff;
+				minorVersion = data[offset++]&0xff;
+				densityUnit = data[offset++]&0xff;
+				xDensity = IOUtils.readUnsignedShortMM(data, offset);
+				offset += 2;
+				yDensity = IOUtils.readUnsignedShortMM(data, offset);
+				offset += 2;
+				thumbnailWidth = data[offset++]&0xff;
+				thumbnailHeight = data[offset]&0xff;
+				if(thumbnailWidth != 0 && thumbnailHeight != 0) {
+					containsThumbnail = true;
+					// Extract the thumbnail
+		    		//Create a Bitmap
+		    		int size = 3*thumbnailWidth*thumbnailHeight;
+					int[] colors = MetadataUtils.toARGB(ArrayUtils.subArray(data, expectedLen, size));
+					thumbnail = new JFIFThumbnail(Bitmap.createBitmap(colors, thumbnailWidth, thumbnailHeight, Bitmap.Config.ARGB_8888));
+				}
+			}
+			
+		    isDataRead = true;
+		}		
+	}
+
+	public void write(OutputStream os) throws IOException {
+		ensureDataRead();
+		IOUtils.write(os, majorVersion);
+		IOUtils.write(os, minorVersion);
+		IOUtils.write(os, densityUnit);
+		IOUtils.writeShortMM(os, getXDensity());
+		IOUtils.writeShortMM(os, getYDensity());
+		IOUtils.write(os, thumbnailWidth);
+		IOUtils.write(os, thumbnailHeight);
+		if(containsThumbnail)
+			thumbnail.write(os);
+	}
+}
diff --git a/src/pixy/meta/jpeg/JFIFThumbnail.java b/src/pixy/meta/jpeg/JFIFThumbnail.java
new file mode 100644
index 0000000..baff893
--- /dev/null
+++ b/src/pixy/meta/jpeg/JFIFThumbnail.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * JFIFThumbnail.java
+ *
+ * Who   Date       Description
+ * ====  =======    =================================================
+ * WY    14Jul2015  Added copy constructor
+ * WY    12Jul2015  Initial creation
+ */
+
+package pixy.meta.jpeg;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import android.graphics.Bitmap;
+import pixy.meta.Thumbnail;
+
+public class JFIFThumbnail extends Thumbnail {
+
+	public JFIFThumbnail(Bitmap thumbnail) {
+		super(thumbnail);
+	}
+	
+	public JFIFThumbnail(JFIFThumbnail other) { // Copy constructor
+		this.dataType = other.dataType;
+		this.height = other.height;
+		this.width = other.width;
+		this.thumbnail = other.thumbnail;
+		this.compressedThumbnail = other.compressedThumbnail;
+	}
+
+	@Override
+	public void write(OutputStream os) throws IOException {
+		Bitmap thumbnail = getRawImage();
+		if(thumbnail == null) throw new IllegalArgumentException("Expected raw data thumbnail does not exist!");
+		int thumbnailWidth = thumbnail.getWidth();
+		int thumbnailHeight = thumbnail.getHeight();
+		int[] pixels = new int[thumbnailWidth*thumbnailHeight];
+		thumbnail.getPixels(pixels, 0, thumbnailWidth, 0, 0, thumbnailWidth, thumbnailHeight);
+		for(int pixel : pixels) {
+			os.write(pixel >> 16); // Red
+			os.write(pixel >> 8); // Green
+			os.write(pixel); // Blue
+		}
+	}
+}
diff --git a/src/pixy/meta/jpeg/JPGMeta.java b/src/pixy/meta/jpeg/JPGMeta.java
new file mode 100644
index 0000000..f07e5c4
--- /dev/null
+++ b/src/pixy/meta/jpeg/JPGMeta.java
@@ -0,0 +1,2128 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * JPGMeta.java
+ *
+ * Who   Date       Description
+ * ====  =======    ===================================================
+ * WY    21Jun2019  Added code to return the removed metadata as a map
+ * WY    13Feb2017  Fixed bug with APP1 segment length too small
+ * WY    06Nov2016  Added support for Cardboard Camera image and audio
+ * WY    07Apr2016  Rewrite insertXMP() to leverage JpegXMP
+ * WY    30Mar2016  Rewrite writeComment() to leverage COMBuilder
+ * WY    06Jul2015  Added insertXMP(InputSream, OutputStream, XMP)
+ * WY    02Jul2015  Added support for APP14 segment reading
+ * WY    02Jul2015  Added support for APP12 segment reading
+ * WY    01Jul2015  Added support for non-standard XMP identifier
+ * WY    15Apr2015  Changed the argument type for insertIPTC() and insertIRB()
+ * WY    07Apr2015  Revised insertExif()
+ * WY    01Apr2015  Extract IPTC as stand-alone meta data from IRB if any
+ * WY    18Mar2015  Revised readAPP13(), insertIPTC() and insertIRB()
+ * 				    to work with multiple APP13 segments
+ * WY    18Mar2015  Removed a few unused readAPPn methods
+ * WY    16Mar2015  Revised insertExif() to put EXIF in the position
+ * 					conforming to EXIF specification or the same place
+ *                  the original image EXIF exists
+ * WY    13Mar2015  initial creation
+ */
+
+package pixy.meta.jpeg;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import pixy.image.tiff.IFD;
+import pixy.image.tiff.TiffTag;
+import pixy.image.jpeg.COMBuilder;
+import pixy.image.jpeg.Component;
+import pixy.image.jpeg.DHTReader;
+import pixy.image.jpeg.DQTReader;
+import pixy.image.jpeg.HTable;
+import pixy.image.jpeg.Marker;
+import pixy.image.jpeg.QTable;
+import pixy.image.jpeg.SOFReader;
+import pixy.image.jpeg.SOSReader;
+import pixy.image.jpeg.Segment;
+import pixy.image.jpeg.UnknownSegment;
+import pixy.io.FileCacheRandomAccessInputStream;
+import pixy.io.IOUtils;
+import pixy.io.RandomAccessInputStream;
+import pixy.string.Base64;
+import pixy.string.StringUtils;
+import pixy.string.XMLUtils;
+import pixy.util.ArrayUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.w3c.dom.Document;
+
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.CompressFormat;
+import pixy.meta.Metadata;
+import pixy.meta.MetadataType;
+import pixy.meta.Thumbnail;
+import pixy.meta.adobe.IRB;
+import pixy.meta.adobe.ImageResourceID;
+import pixy.meta.adobe._8BIM;
+import pixy.meta.exif.Exif;
+import pixy.meta.exif.ExifTag;
+import pixy.meta.exif.ExifThumbnail;
+import pixy.meta.adobe.ThumbnailResource;
+import pixy.meta.icc.ICCProfile;
+import pixy.meta.image.ImageMetadata;
+import pixy.meta.image.Comments;
+import pixy.meta.iptc.IPTC;
+import pixy.meta.iptc.IPTCDataSet;
+import pixy.meta.iptc.IPTCTag;
+import pixy.meta.xmp.XMP;
+import pixy.util.MetadataUtils;
+
+import static pixy.image.jpeg.JPGConsts.*;
+
+/**
+ * JPEG image tweaking tool
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/25/2013
+ */
+public class JPGMeta {
+		
+	public static final EnumSet<Marker> APPnMarkers = EnumSet.range(Marker.APP0, Marker.APP15);
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(JPGMeta.class);
+	
+	private static short copySegment(short marker, InputStream is, OutputStream os) throws IOException {
+		int length = IOUtils.readUnsignedShortMM(is);
+		byte[] buf = new byte[length - 2];
+		IOUtils.readFully(is, buf);
+		IOUtils.writeShortMM(os, marker);
+		IOUtils.writeShortMM(os, (short) length);
+		IOUtils.write(os, buf);
+		
+		return (IOUtils.readShortMM(is));
+	}
+	
+	/** Copy a single SOS segment */	
+	@SuppressWarnings("unused")
+	private static short copySOS(InputStream is, OutputStream os) throws IOException {
+		// Need special treatment.
+		int nextByte = 0;
+		short marker = 0;	
+		
+		while((nextByte = IOUtils.read(is)) != -1) {
+			if(nextByte == 0xff) {
+				nextByte = IOUtils.read(is);
+				
+				if (nextByte == -1) {
+					throw new IOException("Premature end of SOS segment!");					
+				}								
+				
+				if (nextByte != 0x00) { // This is a marker
+					marker = (short)((0xff<<8)|nextByte);
+					
+					switch (Marker.fromShort(marker)) {										
+						case RST0:  
+						case RST1:
+						case RST2:
+						case RST3:
+						case RST4:
+						case RST5:
+						case RST6:
+						case RST7:
+							IOUtils.writeShortMM(os, marker);
+							continue;
+						default:											
+					}
+					break;
+				}
+				IOUtils.write(os, 0xff);
+				IOUtils.write(os, nextByte);
+			} else {
+				IOUtils.write(os,  nextByte);				
+			}			
+		}
+		
+		if (nextByte == -1) {
+			throw new IOException("Premature end of SOS segment!");
+		}
+
+		return marker;
+	}
+	
+	private static void copyToEnd(InputStream is, OutputStream os) throws IOException {
+		byte[] buffer = new byte[10240]; // 10k buffer
+		int bytesRead = -1;
+		
+		while((bytesRead = is.read(buffer)) != -1) {
+			os.write(buffer, 0, bytesRead);
+		}
+	}
+	
+	public static byte[] extractICCProfile(InputStream is) throws IOException {
+		ByteArrayOutputStream bo = new ByteArrayOutputStream();
+		// Flag when we are done
+		boolean finished = false;
+		int length = 0;	
+		short marker;
+		Marker emarker;
+				
+		// The very first marker should be the start_of_image marker!	
+		if(Marker.fromShort(IOUtils.readShortMM(is)) != Marker.SOI)
+			throw new IOException("Invalid JPEG image, expected SOI marker not found!");
+		
+		marker = IOUtils.readShortMM(is);
+		
+		while (!finished) {	        
+			if (Marker.fromShort(marker) == Marker.EOI)	{
+				finished = true;
+			} else { // Read markers
+				emarker = Marker.fromShort(marker);
+	
+				switch (emarker) {
+					case JPG: // JPG and JPGn shouldn't appear in the image.
+					case JPG0:
+					case JPG13:
+				    case TEM: // The only stand alone marker besides SOI, EOI, and RSTn. 
+						marker = IOUtils.readShortMM(is);
+						break;
+				    case PADDING:	
+				    	int nextByte = 0;
+				    	while((nextByte = IOUtils.read(is)) == 0xff) {;}
+				    	marker = (short)((0xff<<8)|nextByte);
+				    	break;				
+				    case SOS:	
+				    	//marker = skipSOS(is);
+				    	finished = true;
+						break;
+				    case APP2:
+				    	readAPP2(is, bo);
+						marker = IOUtils.readShortMM(is);
+						break;
+				    default:
+					    length = IOUtils.readUnsignedShortMM(is);					
+					    byte[] buf = new byte[length - 2];					   
+					    IOUtils.readFully(is, buf);
+					    marker = IOUtils.readShortMM(is);
+				}
+			}
+	    }
+		
+		return bo.toByteArray();
+	}
+	
+	public static void extractICCProfile(InputStream is, String pathToICCProfile) throws IOException {
+		byte[] icc_profile = extractICCProfile(is);
+		
+		if(icc_profile != null && icc_profile.length > 0) {
+			String outpath = "";
+			if(pathToICCProfile.endsWith("\\") || pathToICCProfile.endsWith("/"))
+				outpath = pathToICCProfile + "icc_profile";
+			else
+				outpath = pathToICCProfile.replaceFirst("[.][^.]+$", "");
+			OutputStream os = new FileOutputStream(outpath + ".icc");
+			os.write(icc_profile);
+			os.close();
+		}	
+	}
+	
+	// Extract depth map from google phones and cardboard camera audio & stereo pair
+	public static void extractDepthMap(InputStream is, String pathToDepthMap) throws IOException {
+		Map<MetadataType, Metadata> meta = readMetadata(is);
+		XMP xmp = (XMP)meta.get(MetadataType.XMP);
+		if(xmp != null) {
+			Document xmpDocument = xmp.getMergedDocument();
+			String depthMapMime = XMLUtils.getAttribute(xmpDocument, "rdf:Description", "GDepth:Mime");
+			String depthData = "GDepth:Data";
+			String audioMime = XMLUtils.getAttribute(xmpDocument, "rdf:Description", "GAudio:Mime");
+			if(StringUtils.isNullOrEmpty(depthMapMime)) {
+				depthMapMime = XMLUtils.getAttribute(xmpDocument, "rdf:Description", "GImage:Mime");
+				depthData = "GImage:Data";
+			}					
+			if(!StringUtils.isNullOrEmpty(depthMapMime)) {
+				String data = XMLUtils.getAttribute(xmpDocument, "rdf:Description", depthData);
+				if(!StringUtils.isNullOrEmpty(data)) {
+					String outpath = "";
+					if(pathToDepthMap.endsWith("\\") || pathToDepthMap.endsWith("/"))
+						outpath = pathToDepthMap + "google_depthmap";
+					else
+						outpath = pathToDepthMap.replaceFirst("[.][^.]+$", "") + "_depthmap";
+					if(depthMapMime.equalsIgnoreCase("image/png")) {
+						outpath += ".png";
+					} else if(depthMapMime.equalsIgnoreCase("image/jpeg")) {
+						outpath += ".jpg";
+					}
+					try {
+						byte[] image = Base64.decodeToByteArray(data);							
+						FileOutputStream fout = new FileOutputStream(new File(outpath));
+						fout.write(image);
+						fout.close();
+					} catch (Exception e) {							
+						e.printStackTrace();
+					}
+				}		
+			}
+			if(!StringUtils.isNullOrEmpty(audioMime)) { // Cardboard Camera Audio
+				String data = XMLUtils.getAttribute(xmpDocument, "rdf:Description", "GAudio:Data");
+				if(!StringUtils.isNullOrEmpty(data)) {
+					String outpath = "";
+					if(pathToDepthMap.endsWith("\\") || pathToDepthMap.endsWith("/"))
+						outpath = pathToDepthMap + "google_cardboard_audio";
+					else
+						outpath = pathToDepthMap.replaceFirst("[.][^.]+$", "") + "_cardboard_audio";
+					if(audioMime.equalsIgnoreCase("audio/mp4a-latm")) {
+						outpath += ".mp4";
+					}
+					try {
+						byte[] image = Base64.decodeToByteArray(data);							
+						FileOutputStream fout = new FileOutputStream(new File(outpath));
+						fout.write(image);
+						fout.close();
+					} catch (Exception e) {							
+						e.printStackTrace();
+					}
+				}		
+			}
+		}	
+	}
+	
+	private static void extractMetadataFromAPPn(Collection<Segment> appnSegments, Map<MetadataType, Metadata> metadataMap) throws IOException {
+		// Used to read multiple segment ICCProfile
+		ByteArrayOutputStream iccProfileStream = null;
+		
+		// Used to read multiple segment Adobe APP13
+		ByteArrayOutputStream eightBIMStream = null;
+		
+		// Used to read multiple segment XMP
+		byte[] extendedXMP = null;
+		String xmpGUID = ""; // 32 byte ASCII hex string
+		
+		Map<String, Thumbnail> thumbnails = new HashMap<String, Thumbnail>();
+		
+		for(Segment segment : appnSegments) {
+			byte[] data = segment.getData();
+			int length = segment.getLength();
+			if(segment.getMarker() == Marker.APP0) {
+				if (data.length >= JFIF_ID.length() && new String(data, 0, JFIF_ID.length()).equals(JFIF_ID)) {
+					metadataMap.put(MetadataType.JPG_JFIF, new JFIF(ArrayUtils.subArray(data, JFIF_ID.length(), length - JFIF_ID.length() - 2)));
+				}
+			} else if(segment.getMarker() == Marker.APP1) {
+				// Check for EXIF
+				if(data.length >= EXIF_ID.length() && new String(data, 0, EXIF_ID.length()).equals(EXIF_ID)) {
+					// We found EXIF
+					JpegExif exif = new JpegExif(ArrayUtils.subArray(data, EXIF_ID.length(), length - EXIF_ID.length() - 2));
+					metadataMap.put(MetadataType.EXIF, exif);
+				} else if(data.length >= XMP_ID.length() && new String(data, 0, XMP_ID.length()).equals(XMP_ID) ||
+						data.length >= NON_STANDARD_XMP_ID.length() && new String(data, 0, NON_STANDARD_XMP_ID.length()).equals(NON_STANDARD_XMP_ID)) {
+					// We found XMP, add it to metadata list (We may later revise it if we have ExtendedXMP)
+					XMP xmp = new JpegXMP(ArrayUtils.subArray(data, XMP_ID.length(), length - XMP_ID.length() - 2));
+					metadataMap.put(MetadataType.XMP, xmp);
+					// Retrieve XMP GUID if available
+					xmpGUID = XMLUtils.getAttribute(xmp.getXmpDocument(), "rdf:Description", "xmpNote:HasExtendedXMP");
+				} else if(data.length >= XMP_EXT_ID.length() && new String(data, 0, XMP_EXT_ID.length()).equals(XMP_EXT_ID)) {
+					// We found ExtendedXMP, add the data to ExtendedXMP memory buffer				
+					int i = XMP_EXT_ID.length();
+					// 128-bit MD5 digest of the full ExtendedXMP serialization
+					byte[] guid = ArrayUtils.subArray(data, i, 32);
+					if(Arrays.equals(guid, xmpGUID.getBytes())) { // We have matched the GUID, copy it
+						i += 32;
+						long extendedXMPLength = IOUtils.readUnsignedIntMM(data, i);
+						i += 4;
+						if(extendedXMP == null)
+							extendedXMP = new byte[(int)extendedXMPLength];
+						// Offset for the current segment
+						long offset = IOUtils.readUnsignedIntMM(data, i);
+						i += 4;
+						byte[] xmpBytes = ArrayUtils.subArray(data, i, length - XMP_EXT_ID.length() - 42);
+						System.arraycopy(xmpBytes, 0, extendedXMP, (int)offset, xmpBytes.length);
+					}
+				}
+			} else if(segment.getMarker() == Marker.APP2) {
+				// We're only interested in ICC_Profile
+				if (data.length >= ICC_PROFILE_ID.length() && new String(data, 0, ICC_PROFILE_ID.length()).equals(ICC_PROFILE_ID)) {
+					if(iccProfileStream == null)
+						iccProfileStream = new ByteArrayOutputStream();
+					iccProfileStream.write(ArrayUtils.subArray(data, ICC_PROFILE_ID.length() + 2, length - ICC_PROFILE_ID.length() - 4));
+				}
+			} else if(segment.getMarker() == Marker.APP12) {
+				if (data.length >= DUCKY_ID.length() && new String(data, 0, DUCKY_ID.length()).equals(DUCKY_ID)) {
+					metadataMap.put(MetadataType.JPG_DUCKY, new Ducky(ArrayUtils.subArray(data, DUCKY_ID.length(), length - DUCKY_ID.length() - 2)));
+				}
+			} else if(segment.getMarker() == Marker.APP13) {
+				if (data.length >= PHOTOSHOP_IRB_ID.length() && new String(data, 0, PHOTOSHOP_IRB_ID.length()).equals(PHOTOSHOP_IRB_ID)) {
+					if(eightBIMStream == null)
+						eightBIMStream = new ByteArrayOutputStream();
+					eightBIMStream.write(ArrayUtils.subArray(data, PHOTOSHOP_IRB_ID.length(), length - PHOTOSHOP_IRB_ID.length() - 2));
+				}
+			} else if(segment.getMarker() == Marker.APP14) {
+				if (data.length >= ADOBE_ID.length() && new String(data, 0, ADOBE_ID.length()).equals(ADOBE_ID)) {
+					metadataMap.put(MetadataType.JPG_ADOBE, new Adobe(ArrayUtils.subArray(data, ADOBE_ID.length(), length - ADOBE_ID.length() - 2)));
+				}
+			}
+		}
+		
+		// Now it's time to join multiple segments ICC_PROFILE and/or XMP		
+		if(iccProfileStream != null) { // We have ICCProfile data
+			ICCProfile icc_profile = new ICCProfile(iccProfileStream.toByteArray());
+			metadataMap.put(MetadataType.ICC_PROFILE, icc_profile);
+		}
+		
+		if(eightBIMStream != null) {
+			IRB irb = new IRB(eightBIMStream.toByteArray());	
+			metadataMap.put(MetadataType.PHOTOSHOP_IRB, irb);
+			_8BIM iptc = irb.get8BIM(ImageResourceID.IPTC_NAA.getValue());
+			// Extract IPTC as stand-alone meta
+			if(iptc != null) {
+				metadataMap.put(MetadataType.IPTC, new IPTC(iptc.getData()));
+			}
+		}
+		
+		if(extendedXMP != null) {
+			XMP xmp = ((XMP)metadataMap.get(MetadataType.XMP));
+			if(xmp != null)
+				xmp.setExtendedXMPData(extendedXMP);
+		}
+		
+		// Extract thumbnails to ImageMetadata
+		Metadata meta = metadataMap.get(MetadataType.EXIF);
+		if(meta != null) {
+			Exif exif = (Exif)meta;
+			if(!exif.isDataRead())
+				exif.read();
+			if(exif.containsThumbnail()) {
+				thumbnails.put("EXIF", exif.getThumbnail());
+			}
+		}
+		
+		meta = metadataMap.get(MetadataType.PHOTOSHOP_IRB);
+		if(meta != null) {
+			IRB irb = (IRB)meta;
+			if(!irb.isDataRead())
+				irb.read();
+			if(irb.containsThumbnail()) {
+				thumbnails.put("PHOTOSHOP_IRB", irb.getThumbnail());
+			}
+		}
+		
+		metadataMap.put(MetadataType.IMAGE, new ImageMetadata(thumbnails));
+	}
+	
+	/**
+	 * Extracts thumbnail images from JFIF/APP0, Exif APP1 and/or Adobe APP13 segment if any.
+	 * 
+	 * @param is InputStream for the JPEG image.
+	 * @param pathToThumbnail a path or a path and name prefix combination for the extracted thumbnails.
+	 * @throws IOException
+	 */
+	public static void extractThumbnails(InputStream is, String pathToThumbnail) throws IOException {
+		// Flag when we are done
+		boolean finished = false;
+		int length = 0;	
+		short marker;
+		Marker emarker;
+				
+		// The very first marker should be the start_of_image marker!
+		if(Marker.fromShort(IOUtils.readShortMM(is)) != Marker.SOI)
+			throw new IOException("Invalid JPEG image, expected SOI marker not found!");
+		
+		marker = IOUtils.readShortMM(is);
+		
+		while (!finished) {	        
+			if (Marker.fromShort(marker) == Marker.EOI)	{
+				finished = true;
+			} else { // Read markers
+				emarker = Marker.fromShort(marker);
+		
+				switch (emarker) {
+					case JPG: // JPG and JPGn shouldn't appear in the image.
+					case JPG0:
+					case JPG13:
+				    case TEM: // The only stand alone marker besides SOI, EOI, and RSTn. 
+				    	marker = IOUtils.readShortMM(is);
+						break;
+				    case PADDING:	
+				    	int nextByte = 0;
+				    	while((nextByte = IOUtils.read(is)) == 0xff) {;}
+				    	marker = (short)((0xff<<8)|nextByte);
+				    	break;				
+				    case SOS:	
+						finished = true;
+						break;
+				    case APP0:
+				    	length = IOUtils.readUnsignedShortMM(is);
+						byte[] jfif_buf = new byte[length - 2];
+					    IOUtils.readFully(is, jfif_buf);
+					    // EXIF segment
+					    if(new String(jfif_buf, 0, JFIF_ID.length()).equals(JFIF_ID) || new String(jfif_buf, 0, JFXX_ID.length()).equals(JFXX_ID)) {
+					      	int thumbnailWidth = jfif_buf[12]&0xff;
+					    	int thumbnailHeight = jfif_buf[13]&0xff;
+					    	String outpath = "";
+							if(pathToThumbnail.endsWith("\\") || pathToThumbnail.endsWith("/"))
+								outpath = pathToThumbnail + "jfif_thumbnail";
+							else
+								outpath = pathToThumbnail.replaceFirst("[.][^.]+$", "") + "_jfif_t";
+					    	
+					    	if(thumbnailWidth != 0 && thumbnailHeight != 0) { // There is a thumbnail
+					    		// Extract the thumbnail
+					    		//Create a BufferedImage
+					    		int size = 3*thumbnailWidth*thumbnailHeight;
+								int[] colors = MetadataUtils.toARGB(ArrayUtils.subArray(jfif_buf, 14, size));
+								Bitmap bmp = Bitmap.createBitmap(colors, thumbnailWidth, thumbnailHeight, Bitmap.Config.ARGB_8888);
+								FileOutputStream fout = new FileOutputStream(outpath + ".jpg");
+								try {
+									bmp.compress(CompressFormat.JPEG, 100, fout);
+								} catch (Exception e) {
+									e.printStackTrace();
+								}
+								fout.close();		
+					    	}
+					    }
+				    	marker = IOUtils.readShortMM(is);
+						break;
+				    case APP1:
+				    	// EXIF identifier with trailing bytes [0x00,0x00].
+						byte[] exif_buf = new byte[EXIF_ID.length()];
+						length = IOUtils.readUnsignedShortMM(is);						
+						IOUtils.readFully(is, exif_buf);						
+						// EXIF segment.
+						if (Arrays.equals(exif_buf, EXIF_ID.getBytes())) {
+							exif_buf = new byte[length - 8];
+						    IOUtils.readFully(is, exif_buf);
+						    Exif exif = new JpegExif(exif_buf);
+						    if(exif.containsThumbnail()) {
+						    	String outpath = "";
+								if(pathToThumbnail.endsWith("\\") || pathToThumbnail.endsWith("/"))
+									outpath = pathToThumbnail + "exif_thumbnail";
+								else
+									outpath = pathToThumbnail.replaceFirst("[.][^.]+$", "") + "_exif_t";
+						    	Thumbnail thumbnail = exif.getThumbnail();
+						    	OutputStream fout = null;
+						    	if(thumbnail.getDataType() == ExifThumbnail.DATA_TYPE_KJpegRGB) {// JPEG format, save as JPEG
+						    		 fout = new FileOutputStream(outpath + ".jpg");						    	
+						    	} else { // Uncompressed, save as TIFF
+						    		fout = new FileOutputStream(outpath + ".tif");
+						    	}
+						    	fout.write(thumbnail.getCompressedImage());
+					    		fout.close();
+						    }						  			
+						} else {
+							IOUtils.skipFully(is, length - 8);
+						}
+						marker = IOUtils.readShortMM(is);
+						break;
+				    case APP13:
+				    	length = IOUtils.readUnsignedShortMM(is);
+						byte[] data = new byte[length - 2];
+						IOUtils.readFully(is, data, 0, length - 2);						
+						int i = 0;
+						
+						while(data[i] != 0) i++;
+						
+						if(new String(data, 0, i++).equals("Photoshop 3.0")) {
+							IRB irb = new IRB(ArrayUtils.subArray(data, i, data.length - i));
+							if(irb.containsThumbnail()) {
+								Thumbnail thumbnail = irb.getThumbnail();
+								// Create output path
+								String outpath = "";
+								if(pathToThumbnail.endsWith("\\") || pathToThumbnail.endsWith("/"))
+									outpath = pathToThumbnail + "photoshop_thumbnail.jpg";
+								else
+									outpath = pathToThumbnail.replaceFirst("[.][^.]+$", "") + "_photoshop_t.jpg";
+								FileOutputStream fout = new FileOutputStream(outpath);
+								if(thumbnail.getDataType() == Thumbnail.DATA_TYPE_KJpegRGB) {
+									fout.write(thumbnail.getCompressedImage());
+								} else {
+									Bitmap bmp = thumbnail.getRawImage();
+									try {
+										 bmp.compress(Bitmap.CompressFormat.JPEG, 100, fout);
+									} catch (Exception e) {
+										throw new IOException("Writing thumbnail failed!");
+									}
+								}
+								fout.close();								
+							}							
+						}				
+				    	marker = IOUtils.readShortMM(is);
+				    	break;
+				    default:
+					    length = IOUtils.readUnsignedShortMM(is);					
+					    byte[] buf = new byte[length - 2];
+					    IOUtils.readFully(is, buf);
+					    marker = IOUtils.readShortMM(is);
+				}
+			}
+	    }
+	}
+	
+	public static ICCProfile getICCProfile(InputStream is) throws IOException {
+		ICCProfile profile = null;
+		byte[] buf = extractICCProfile(is);
+		if(buf.length > 0)
+			profile = new ICCProfile(buf);
+		return profile;
+	}
+	
+	public static void insertComments(InputStream is, OutputStream os, List<String> comments) throws IOException {
+		boolean finished = false;
+		short marker;
+		Marker emarker;
+				
+		// The very first marker should be the start_of_image marker!	
+		if(Marker.fromShort(IOUtils.readShortMM(is)) != Marker.SOI)
+			throw new IOException("Invalid JPEG image, expected SOI marker not found!");
+	
+		IOUtils.writeShortMM(os, Marker.SOI.getValue());
+		
+		marker = IOUtils.readShortMM(is);
+		
+		while (!finished) {	        
+			if (Marker.fromShort(marker) == Marker.SOS) {
+				// Write comment
+				for(String comment : comments)
+					writeComment(comment, os);
+				// Copy the rest of the data
+				IOUtils.writeShortMM(os, marker);
+				copyToEnd(is, os);
+				// No more marker to read, we are done.
+				finished = true;  
+			}  else { // Read markers
+				emarker = Marker.fromShort(marker);
+			
+				switch (emarker) {
+					case JPG: // JPG and JPGn shouldn't appear in the image.
+					case JPG0:
+					case JPG13:
+					case TEM: // The only stand alone marker besides SOI, EOI, and RSTn.
+						IOUtils.writeShortMM(os, marker);
+						marker = IOUtils.readShortMM(is);
+						break;
+					case PADDING:
+						IOUtils.writeShortMM(os, marker);
+						int nextByte = 0;
+						while ((nextByte = IOUtils.read(is)) == 0xff) {
+							IOUtils.write(os, nextByte);
+						}
+						marker = (short) ((0xff << 8) | nextByte);
+						break;
+				    default:
+						marker = copySegment(marker, is, os);
+				}
+			}
+	    }
+	}
+	
+	/**
+	 * @param is input image stream 
+	 * @param os output image stream
+	 * @param exif Exif instance
+	 * @param update True to keep the original data, otherwise false
+	 * @throws Exception 
+	 */
+	public static void insertExif(InputStream is, OutputStream os, Exif exif, boolean update) throws IOException {
+		// We need thumbnail image but don't have one, create one from the current image input stream
+		if(exif.isThumbnailRequired() && !exif.containsThumbnail()) {
+			is = new FileCacheRandomAccessInputStream(is);
+			// Insert thumbnail into EXIF wrapper
+			exif.setThumbnailImage(MetadataUtils.createThumbnail(is));
+		}
+		Exif oldExif = null;
+		int app0Index = -1;
+		// Copy the original image and insert EXIF data
+		boolean finished = false;
+		int length = 0;
+		short marker;
+		Marker emarker;
+				
+		// The very first marker should be the start_of_image marker!
+		if(Marker.fromShort(IOUtils.readShortMM(is)) != Marker.SOI)	{
+			throw new IOException("Invalid JPEG image, expected SOI marker not found!");
+		}
+		
+		IOUtils.writeShortMM(os, Marker.SOI.getValue());
+		
+		marker = IOUtils.readShortMM(is);
+		
+		// Create a list to hold the temporary Segments 
+		List<Segment> segments = new ArrayList<Segment>();
+		
+		while (!finished) { // Read through and add the segments to a list until SOS 
+			if (Marker.fromShort(marker) == Marker.SOS) {
+				// Write the items in segments list excluding the old EXIF
+				for(int i = 0; i <= app0Index; i++) {
+					segments.get(i).write(os);
+				}
+				// Now we insert the EXIF data
+		    	IFD newExifSubIFD = exif.getExifIFD();
+		    	IFD newGpsSubIFD = exif.getGPSIFD();
+		    	IFD newImageIFD = exif.getImageIFD();
+		    	IFD newInteropSubIFD = exif.getInteropIFD();
+		    	ExifThumbnail newThumbnail = exif.getThumbnail();  
+		    	// Define new IFDs
+		    	IFD exifSubIFD = null;
+		    	IFD gpsSubIFD = null;
+		    	IFD interopSubIFD = null;
+		    	IFD imageIFD = null;
+		    	// Got to do something to keep the old data
+		    	if(update && oldExif != null) {
+		    		exif.setPreferredEndian(oldExif.getPreferredEndian());
+		    		IFD oldImageIFD = oldExif.getImageIFD();
+			    	IFD oldExifSubIFD = oldExif.getExifIFD();
+			    	IFD oldGpsSubIFD = oldExif.getGPSIFD();
+			    	IFD oldInteropSubIFD = oldExif.getInteropIFD();
+			    	
+			    	ExifThumbnail thumbnail = oldExif.getThumbnail();
+			    	
+			    	if(oldImageIFD != null) {
+			    		imageIFD = new IFD();
+			    		imageIFD.addFields(oldImageIFD.getFields());
+			    	}
+			    	if(thumbnail != null) {
+			    		if(newThumbnail == null)
+			    			newThumbnail = thumbnail;
+					}
+			    	if(oldExifSubIFD != null) {
+			    		exifSubIFD = new IFD();
+			    		exifSubIFD.addFields(oldExifSubIFD.getFields());
+			    	}
+			    	if(oldInteropSubIFD != null) {
+			    		interopSubIFD = new IFD();
+			    		interopSubIFD.addFields(oldInteropSubIFD.getFields());
+			    	}
+			    	if(oldGpsSubIFD != null) {
+			    		gpsSubIFD = new IFD();
+			    		gpsSubIFD.addFields(oldGpsSubIFD.getFields());
+					}
+		    	}
+		    	if(newImageIFD != null) {
+		    		if(imageIFD == null)
+		    			imageIFD = new IFD();
+		    		imageIFD.addFields(newImageIFD.getFields());
+		    	}
+		    	if(exifSubIFD != null) {
+		    		if(newExifSubIFD != null)
+		    			exifSubIFD.addFields(newExifSubIFD.getFields());
+		    	} else
+		    		exifSubIFD = newExifSubIFD;
+		    	if(interopSubIFD != null) {
+		    		if(newInteropSubIFD != null)
+		    			interopSubIFD.addFields(newInteropSubIFD.getFields());
+		    	} else
+		    		interopSubIFD = newInteropSubIFD;
+		    	if(gpsSubIFD != null) {
+		    		if(newGpsSubIFD != null)
+		    			gpsSubIFD.addFields(newGpsSubIFD.getFields());
+		    	} else
+		    		gpsSubIFD = newGpsSubIFD;
+		    	// If we have ImageIFD, set Image IFD attached with EXIF and GPS
+		     	if(imageIFD != null) {
+		    		if(exifSubIFD != null) {
+			    		imageIFD.addChild(TiffTag.EXIF_SUB_IFD, exifSubIFD);
+			    		if(interopSubIFD != null) {
+				    		exifSubIFD.addChild(ExifTag.EXIF_INTEROPERABILITY_OFFSET, interopSubIFD);
+			    		}
+		    		}
+		    		if(gpsSubIFD != null)
+			    		imageIFD.addChild(TiffTag.GPS_SUB_IFD, gpsSubIFD);
+		    		exif.setImageIFD(imageIFD);
+		    	} else { // Otherwise, set EXIF and GPS IFD separately
+		    		exif.setExifIFD(exifSubIFD);
+		    		exif.setGPSIFD(gpsSubIFD);
+		    		exif.setInteropIFD(interopSubIFD);
+		    	}
+		   		exif.setThumbnail(newThumbnail);
+		   		// Now insert the new EXIF to the JPEG
+		   		exif.write(os);
+		     	// Copy the remaining segments
+				for(int i = app0Index + 1; i < segments.size(); i++) {
+					segments.get(i).write(os);
+				}	    	
+				// Copy the leftover stuff
+				IOUtils.writeShortMM(os, marker);
+				copyToEnd(is, os);
+				// We are done
+				finished = true;
+			} else { // Read markers
+				emarker = Marker.fromShort(marker);
+		
+				switch (emarker) {
+					case JPG: // JPG and JPGn shouldn't appear in the image.
+					case JPG0:
+					case JPG13:
+				    case TEM: // The only stand alone marker besides SOI, EOI, and RSTn.
+				    	segments.add(new Segment(emarker, 0, null));
+						marker = IOUtils.readShortMM(is);
+						break;
+				    case APP1:
+				    	// Read and remove the old EXIF data
+				    	length = IOUtils.readUnsignedShortMM(is);
+				    	byte[] exifBytes = new byte[length - 2];
+						IOUtils.readFully(is, exifBytes);
+						// Add data to segment list
+						segments.add(new Segment(emarker, length, exifBytes));
+						// Read the EXIF data.
+						if(exifBytes.length >= EXIF_ID.length() && new String(exifBytes, 0, EXIF_ID.length()).equals(EXIF_ID)) { // We assume EXIF data exist only in one APP1
+							oldExif = new JpegExif(ArrayUtils.subArray(exifBytes, EXIF_ID.length(), length - EXIF_ID.length() - 2));
+							segments.remove(segments.size() - 1);
+						}											
+						marker = IOUtils.readShortMM(is);
+						break;
+				    case APP0:
+				    	app0Index = segments.size();
+				    default:
+					    length = IOUtils.readUnsignedShortMM(is);				
+					    byte[] buf = new byte[length - 2];
+					    IOUtils.readFully(is, buf);
+					    if(emarker == Marker.UNKNOWN)
+					    	segments.add(new UnknownSegment(marker, length, buf));
+					    else
+					    	segments.add(new Segment(emarker, length, buf));
+					    marker = IOUtils.readShortMM(is);
+				}
+			}
+	    }
+		// Close the input stream in case it's an instance of RandomAccessInputStream
+		if(is instanceof RandomAccessInputStream)
+			((FileCacheRandomAccessInputStream)is).shallowClose();
+	}
+	
+	/**
+	 * Insert ICC_Profile as one or more APP2 segments
+	 * 
+	 * @param is input stream for the original image
+	 * @param os output stream to write the ICC_Profile
+	 * @param data ICC_Profile data array to be inserted
+	 * @throws IOException
+	 */	
+	public static void insertICCProfile(InputStream is, OutputStream os, byte[] data) throws IOException {
+		// Copy the original image and insert ICC_Profile data
+		byte[] icc_profile_id = {0x49, 0x43, 0x43, 0x5f, 0x50, 0x52, 0x4f, 0x46, 0x49, 0x4c, 0x45, 0x00};
+		boolean finished = false;
+		int length = 0;	
+		short marker;
+		Marker emarker;
+		int app0Index = -1;
+		int app1Index = -1;
+				
+		// The very first marker should be the start_of_image marker!	
+		if(Marker.fromShort(IOUtils.readShortMM(is)) != Marker.SOI)
+			throw new IOException("Invalid JPEG image, expected SOI marker not found!");
+	
+		IOUtils.writeShortMM(os, Marker.SOI.getValue());
+		
+		marker = IOUtils.readShortMM(is);
+		
+		// Create a list to hold the temporary Segments 
+		List<Segment> segments = new ArrayList<Segment>();
+		
+		while (!finished) {	        
+			if (Marker.fromShort(marker) == Marker.SOS) {
+				int index = Math.max(app0Index, app1Index);
+				// Write the items in segments list excluding the APP13
+				for(int i = 0; i <= index; i++)
+					segments.get(i).write(os);	
+				writeICCProfile(os, data);
+		    	// Copy the remaining segments
+				for(int i = (index < 0 ? 0 : index + 1); i < segments.size(); i++) {
+					segments.get(i).write(os);
+				}
+				// Copy the rest of the data
+				IOUtils.writeShortMM(os, marker);
+				copyToEnd(is, os);
+				// No more marker to read, we are done.
+				finished = true;  
+			}  else { // Read markers
+				emarker = Marker.fromShort(marker);
+			
+				switch (emarker) {
+					case JPG: // JPG and JPGn shouldn't appear in the image.
+					case JPG0:
+					case JPG13:
+					case TEM: // The only stand alone marker besides SOI, EOI, and RSTn.
+						segments.add(new Segment(emarker, 0, null));
+						marker = IOUtils.readShortMM(is);
+						break;
+				    case APP2: // Remove old ICC_Profile
+				    	byte[] icc_profile_buf = new byte[12];
+						length = IOUtils.readUnsignedShortMM(is);						
+						if(length < 14) { // This is not an ICC_Profile segment, copy it
+							icc_profile_buf = new byte[length - 2];
+							IOUtils.readFully(is, icc_profile_buf);
+							segments.add(new Segment(emarker, length, icc_profile_buf));
+						} else {
+							IOUtils.readFully(is, icc_profile_buf);		
+							// ICC_PROFILE segment.
+							if (Arrays.equals(icc_profile_buf, icc_profile_id)) {
+								IOUtils.skipFully(is, length - 14);
+							} else {// Not an ICC_Profile segment, copy it
+								IOUtils.writeShortMM(os, marker);
+								IOUtils.writeShortMM(os, (short)length);
+								IOUtils.write(os, icc_profile_buf);
+								byte[] temp = new byte[length - ICC_PROFILE_ID.length() - 2];
+								IOUtils.readFully(is, temp);
+								segments.add(new Segment(emarker, length, ArrayUtils.concat(icc_profile_buf, temp)));
+							}
+						}						
+						marker = IOUtils.readShortMM(is);
+						break;
+				    case APP0:
+				    	app0Index = segments.size();
+				    case APP1:
+				    	app1Index = segments.size();
+				    default:
+				    	 length = IOUtils.readUnsignedShortMM(is);					
+				    	 byte[] buf = new byte[length - 2];
+				    	 IOUtils.readFully(is, buf);
+				    	 if(emarker == Marker.UNKNOWN)
+					    	segments.add(new UnknownSegment(marker, length, buf));
+				    	 else
+					    	segments.add(new Segment(emarker, length, buf));
+				    	 marker = IOUtils.readShortMM(is);
+				}
+			}
+	    }
+	}
+	
+	public static void insertICCProfile(InputStream is, OutputStream os, ICCProfile icc_profile) throws Exception {
+		insertICCProfile(is, os, icc_profile.getData());
+	}
+	
+	/**
+	 * Inserts a list of IPTCDataSet into a JPEG APP13 Photoshop IRB segment
+	 * 
+	 * @param is InputStream for the original image
+	 * @param os OutputStream for the image with IPTC inserted
+	 * @param iptcs a collection of IPTCDataSet to be inserted
+	 * @param update boolean if true, keep the original IPTC data; otherwise, replace it completely with the new IPTC data 
+	 * @throws IOException
+	 */
+	public static void insertIPTC(InputStream is, OutputStream os, Collection<IPTCDataSet> iptcs, boolean update) throws IOException {
+		// Copy the original image and insert Photoshop IRB data
+		boolean finished = false;
+		int length = 0;	
+		short marker;
+		Marker emarker;
+		int app0Index = -1;
+		int app1Index = -1;		
+		
+		Map<Short, _8BIM> bimMap = null;
+		// Used to read multiple segment Adobe APP13
+		ByteArrayOutputStream eightBIMStream = null;
+				
+		// The very first marker should be the start_of_image marker!	
+		if(Marker.fromShort(IOUtils.readShortMM(is)) != Marker.SOI)	{
+			is.close();
+			os.close();		
+			throw new IOException("Invalid JPEG image, expected SOI marker not found!");			
+		}
+		
+		IOUtils.writeShortMM(os, Marker.SOI.getValue());
+		
+		marker = IOUtils.readShortMM(is);
+		
+		// Create a list to hold the temporary Segments 
+		List<Segment> segments = new ArrayList<Segment>();
+		
+		while (!finished) {	        
+			if (Marker.fromShort(marker) == Marker.SOS) {
+				if(eightBIMStream != null) {
+					IRB irb = new IRB(eightBIMStream.toByteArray());
+		    		// Shallow copy the map.
+		    		bimMap = new HashMap<Short, _8BIM>(irb.get8BIM());
+					_8BIM iptcBIM = bimMap.remove(ImageResourceID.IPTC_NAA.getValue());
+					if(iptcBIM != null && update) { // Keep the original values
+						IPTC iptc = new IPTC(iptcBIM.getData());
+						// Shallow copy the map
+						Map<IPTCTag, List<IPTCDataSet>> dataSetMap = new HashMap<IPTCTag, List<IPTCDataSet>>(iptc.getDataSets());
+						for(IPTCDataSet set : iptcs)
+							if(!set.allowMultiple())
+								dataSetMap.remove(set.getName());
+						for(List<IPTCDataSet> iptcList : dataSetMap.values())
+							iptcs.addAll(iptcList);
+					}
+			  	}				
+				int index = Math.max(app0Index, app1Index);
+				// Write the items in segments list excluding the APP13
+				for(int i = 0; i <= index; i++)
+					segments.get(i).write(os);	
+				ByteArrayOutputStream bout = new ByteArrayOutputStream();
+				// Insert IPTC data as one of IRB 8BIM block
+				for(IPTCDataSet iptc : iptcs)
+					iptc.write(bout);
+				// Create 8BIM for IPTC
+				_8BIM newBIM = new _8BIM(ImageResourceID.IPTC_NAA.getValue(), "iptc", bout.toByteArray());
+				if(bimMap != null) {
+					bimMap.put(newBIM.getID(), newBIM); // Add the IPTC_NAA 8BIM to the map
+					writeIRB(os, bimMap.values()); // Write the whole thing as one APP13
+				} else {
+					writeIRB(os, newBIM); // Write the one and only one 8BIM as one APP13
+				}						
+				// Copy the remaining segments
+				for(int i = (index < 0 ? 0 : index + 1); i < segments.size(); i++) {
+					segments.get(i).write(os);
+				}
+				// Copy the rest of the data
+				IOUtils.writeShortMM(os, marker);
+				copyToEnd(is, os);
+				// No more marker to read, we are done.
+				finished = true;  
+			} else {// Read markers
+		   		emarker = Marker.fromShort(marker);
+		
+				switch (emarker) {
+					case JPG: // JPG and JPGn shouldn't appear in the image.
+					case JPG0:
+					case JPG13:
+					case TEM: // The only stand alone marker besides SOI, EOI, and RSTn.
+						segments.add(new Segment(emarker, 0, null));
+						marker = IOUtils.readShortMM(is);
+						break;
+					case APP13:
+			    		if(eightBIMStream == null)
+			    			eightBIMStream = new ByteArrayOutputStream();
+			    		readAPP13(is, eightBIMStream);
+				    	marker = IOUtils.readShortMM(is);
+				    	break;
+					case APP0:
+						app0Index = segments.size();
+					case APP1:
+						app1Index = segments.size();
+				    default:
+				    	length = IOUtils.readUnsignedShortMM(is);					
+					    byte[] buf = new byte[length - 2];
+					    IOUtils.readFully(is, buf);
+					    if(emarker == Marker.UNKNOWN)
+					    	segments.add(new UnknownSegment(marker, length, buf));
+					    else
+					    	segments.add(new Segment(emarker, length, buf));
+					    marker = IOUtils.readShortMM(is);
+				}
+			}
+	    }
+	}
+	
+	/**
+	 * Inserts a collection of _8BIM into a JPEG APP13 Photoshop IRB segment
+	 * 
+	 * @param is InputStream for the original image
+	 * @param os OutputStream for the image with _8BIMs inserted
+	 * @param bims a collection of _8BIM to be inserted
+	 * @param update boolean if true, keep the other _8BIMs; otherwise, replace the whole IRB with the inserted _8BIMs 
+	 * @throws IOException
+	 */
+	public static void insertIRB(InputStream is, OutputStream os, Collection<_8BIM> bims, boolean update) throws IOException {
+		// Copy the original image and insert Photoshop IRB data
+		boolean finished = false;
+		int length = 0;	
+		short marker;
+		Marker emarker;
+		int app0Index = -1;
+		int app1Index = -1;		
+		// Used to read multiple segment Adobe APP13
+		ByteArrayOutputStream eightBIMStream = null;
+				
+		// The very first marker should be the start_of_image marker!	
+		if(Marker.fromShort(IOUtils.readShortMM(is)) != Marker.SOI)	{
+			is.close();
+			os.close();
+			throw new IOException("Invalid JPEG image, expected SOI marker not found!");
+		}
+		
+		IOUtils.writeShortMM(os, Marker.SOI.getValue());
+		
+		marker = IOUtils.readShortMM(is);
+		
+		// Create a list to hold the temporary Segments 
+		List<Segment> segments = new ArrayList<Segment>();
+		
+		while (!finished) {	        
+			if (Marker.fromShort(marker) == Marker.SOS) {
+				if(eightBIMStream != null) {
+					IRB irb = new IRB(eightBIMStream.toByteArray());
+			    	// Shallow copy the map.
+		    		Map<Short, _8BIM> bimMap = new HashMap<Short, _8BIM>(irb.get8BIM());
+					for(_8BIM bim : bims) // Replace the original data
+						bimMap.put(bim.getID(), bim);
+					// In case we have two ThumbnailResource IRB, remove the Photoshop4.0 one
+					if(bimMap.containsKey(ImageResourceID.THUMBNAIL_RESOURCE_PS4.getValue()) 
+							&& bimMap.containsKey(ImageResourceID.THUMBNAIL_RESOURCE_PS5.getValue()))
+						bimMap.remove(ImageResourceID.THUMBNAIL_RESOURCE_PS4.getValue());
+					bims = bimMap.values();					
+		    	}
+				int index = Math.max(app0Index, app1Index);
+				// Write the items in segments list excluding the APP13
+				for(int i = 0; i <= index; i++)
+					segments.get(i).write(os);	
+				writeIRB(os, bims);
+				// Copy the remaining segments
+				for(int i = (index < 0 ? 0 : index + 1); i < segments.size(); i++) {
+					segments.get(i).write(os);
+				}
+				// Copy the rest of the data
+				IOUtils.writeShortMM(os, marker);
+				copyToEnd(is, os);
+				// No more marker to read, we are done.
+				finished = true;  
+			} else {// Read markers
+		   		emarker = Marker.fromShort(marker);
+		
+				switch (emarker) {
+					case JPG: // JPG and JPGn shouldn't appear in the image.
+					case JPG0:
+					case JPG13:
+					case TEM: // The only stand alone marker besides SOI, EOI, and RSTn.
+						segments.add(new Segment(emarker, 0, null));
+						marker = IOUtils.readShortMM(is);
+						break;
+				    case APP13: // We will keep the other IRBs from the original APP13
+				    	if(update) {
+				    		if(eightBIMStream == null)
+				    			eightBIMStream = new ByteArrayOutputStream();
+					    	readAPP13(is, eightBIMStream);					    	
+				    	} else {
+				    		length = IOUtils.readUnsignedShortMM(is);					
+						    IOUtils.skipFully(is, length - 2);
+				    	}
+				    	marker = IOUtils.readShortMM(is);
+				    	break;
+				    case APP0:
+				    	app0Index = segments.size();
+				    case APP1:
+				    	app1Index = segments.size();
+				    default:
+				    	length = IOUtils.readUnsignedShortMM(is);					
+					    byte[] buf = new byte[length - 2];
+					    IOUtils.readFully(is, buf);
+					    if(emarker == Marker.UNKNOWN)
+					    	segments.add(new UnknownSegment(marker, length, buf));
+					    else
+					    	segments.add(new Segment(emarker, length, buf));
+					    marker = IOUtils.readShortMM(is);
+				}
+			}
+	    }
+	}
+	
+	public static void insertIRBThumbnail(InputStream is, OutputStream os, Bitmap thumbnail) throws IOException {
+		// Sanity check
+		if(thumbnail == null) throw new IllegalArgumentException("Input thumbnail is null");
+		_8BIM bim = new ThumbnailResource(thumbnail);
+		insertIRB(is, os, Arrays.asList(bim), true); // Set true to keep other IRB blocks
+	}
+	
+	/**
+	 * Insert a XMP instance into the image. 
+	 * The XMP instance must be able to fit into one APP1.
+	 * 
+	 * @param is InputStream for the image.
+	 * @param os OutputStream for the image.
+	 * @param xmp XMP instance
+	 * @throws IOException
+	 */
+	public static void insertXMP(InputStream is, OutputStream os, XMP xmp) throws IOException {
+		boolean finished = false;
+		int length = 0;	
+		short marker;
+		Marker emarker;
+		int app0Index = -1;
+		int exifIndex = -1;
+		
+		// The very first marker should be the start_of_image marker!	
+		if(Marker.fromShort(IOUtils.readShortMM(is)) != Marker.SOI)	{
+			throw new IOException("Invalid JPEG image, expected SOI marker not found!");
+		}
+		
+		IOUtils.writeShortMM(os, Marker.SOI.getValue());
+		
+		marker = IOUtils.readShortMM(is);
+		
+		// Create a list to hold the temporary Segments 
+		List<Segment> segments = new ArrayList<Segment>();
+		
+		while (!finished) {	        
+			if (Marker.fromShort(marker) == Marker.SOS)	{
+				int index = Math.max(app0Index, exifIndex);
+				// Write the items in segments list excluding the old XMP
+				for(int i = 0; i <= index; i++)
+					segments.get(i).write(os);				
+				// Now we insert the XMP data
+				xmp.write(os);
+				// Copy the remaining segments
+				for(int i = (index < 0 ? 0 : index + 1); i < segments.size(); i++) {
+					segments.get(i).write(os);
+				}	
+				// Copy the leftover stuff
+				IOUtils.writeShortMM(os, marker);
+				copyToEnd(is, os); // Copy the rest of the data
+				finished = true; // No more marker to read, we are done.				
+			} else { // Read markers
+				emarker = Marker.fromShort(marker);
+				
+				switch (emarker) {
+					case JPG: // JPG and JPGn shouldn't appear in the image.
+					case JPG0:
+					case JPG13:
+				    case TEM: // The only stand alone marker besides SOI, EOI, and RSTn.
+				    	segments.add(new Segment(emarker, 0, null));
+						marker = IOUtils.readShortMM(is);
+						break;
+				    case APP1:
+				        // Read and remove the old XMP data
+				        length = IOUtils.readUnsignedShortMM(is);
+				        byte[] temp = new byte[length - 2];
+				        IOUtils.readFully(is, temp);
+				        // Remove XMP and ExtendedXMP segments.
+				        if(temp.length >= XMP_EXT_ID.length() && new String(temp, 0, XMP_EXT_ID.length()).equals(XMP_EXT_ID)) {
+				                ;
+				        } else if (temp.length >= XMP_ID.length() && new String(temp, 0, XMP_ID.length()).equals(XMP_ID)) {
+				                ;
+				        } else {                                               
+				                segments.add(new Segment(emarker, length, temp));
+				                // If it's EXIF, we keep the index
+				                if(temp.length >= EXIF_ID.length() && new String(temp, 0, EXIF_ID.length()).equals(EXIF_ID)) {
+				                        exifIndex = segments.size() - 1;
+				                }
+				        }
+				        marker = IOUtils.readShortMM(is);
+				        break;
+				    case APP0:
+				    	app0Index = segments.size();
+				    default:
+					    length = IOUtils.readUnsignedShortMM(is);					
+					    byte[] buf = new byte[length - 2];
+					    IOUtils.readFully(is, buf);
+					    if(emarker == Marker.UNKNOWN)
+					    	segments.add(new UnknownSegment(marker, length, buf));
+					    else
+					    	segments.add(new Segment(emarker, length, buf));
+					    marker = IOUtils.readShortMM(is);
+				}
+			}
+	    }
+	}
+	
+	/**
+	 * Insert XMP into single APP1 or multiple segments. Support ExtendedXMP.
+	 * The standard part of the XMP must be a valid XMP with packet wrapper and,
+	 * should already include the GUID for the ExtendedXMP in case of ExtendedXMP.
+	 * <p>
+	 * When converted to bytes, the XMP part should be able to fit into one APP1.
+	 * 
+	 * @param is InputStream for the image.
+	 * @param os OutputStream for the image.
+	 * @param xmp XML string for the XMP - Assuming in UTF-8 format.
+	 * @param extendedXmp XML string for the extended XMP - Assuming in UTF-8 format
+	 * 
+	 * @throws IOException
+	 */
+	public static void insertXMP(InputStream is, OutputStream os, String xmp, String extendedXmp) throws IOException {
+		insertXMP(is, os, new JpegXMP(xmp, extendedXmp));
+	}
+	
+	private static String hTablesToString(List<HTable> hTables) {
+		final String[] HT_class_table = {"DC Component", "AC Component"};
+		
+		StringBuilder hufTable = new StringBuilder();
+		
+		hufTable.append("Huffman table information =>:\n");
+		
+		for(HTable table : hTables )
+		{
+			hufTable.append("Class: " + table.getClazz() + " (" + HT_class_table[table.getClazz()] + ")\n");
+			hufTable.append("Huffman table #: " + table.getID() + "\n");
+			
+			byte[] bits = table.getBits();
+			byte[] values = table.getValues();
+			
+		    int count = 0;
+			
+			for (int i = 0; i < bits.length; i++)
+			{
+				count += (bits[i]&0xff);
+			}
+			
+            hufTable.append("Number of codes: " + count + "\n");
+			
+            if (count > 256)
+            	throw new RuntimeException("Invalid huffman code count: " + count);			
+	        
+            int j = 0;
+            
+			for (int i = 0; i < 16; i++) {
+			
+				hufTable.append("Codes of length " + (i+1) + " (" + (bits[i]&0xff) +  " total): [ ");
+				
+				for (int k = 0; k < (bits[i]&0xff); k++) {
+					hufTable.append((values[j++]&0xff) + " ");
+				}
+				
+				hufTable.append("]\n");
+			}
+			
+			hufTable.append("<<End of Huffman table information>>\n");
+		}
+		
+		return hufTable.toString();
+	}
+	
+	private static String qTablesToString(List<QTable> qTables) {
+		StringBuilder qtTables = new StringBuilder();
+				
+		qtTables.append("Quantization table information =>:\n");
+		
+		int count = 0;
+		
+		for(QTable table : qTables) {
+			int QT_precision = table.getPrecision();
+			int[] qTable = table.getData();
+			qtTables.append("precision of QT is " + QT_precision + "\n");
+			qtTables.append("Quantization table #" + table.getID() + ":\n");
+			
+		   	if(QT_precision == 0) {
+				for (int j = 0; j < 64; j++)
+			    {
+					if (j != 0 && j%8 == 0) {
+						qtTables.append("\n");
+					}
+					qtTables.append(qTable[j] + " ");			
+			    }
+			} else { // 16 bit big-endian
+								
+				for (int j = 0; j < 64; j++) {
+					if (j != 0 && j%8 == 0) {
+						qtTables.append("\n");
+					}
+					qtTables.append(qTable[j] + " ");	
+				}				
+			}
+		   	
+		   	count++;
+		
+			qtTables.append("\n");
+			qtTables.append("***************************\n");
+		}
+		
+		qtTables.append("Total number of Quantation tables: " + count + "\n");
+		qtTables.append("End of quantization table information\n");
+		
+		return qtTables.toString();		
+	}
+		
+	private static String sofToString(SOFReader reader) {
+		StringBuilder sof = new StringBuilder();		
+		sof.append("SOF information =>\n");
+		sof.append("Precision: " + reader.getPrecision() + "\n");
+		sof.append("Image height: " + reader.getFrameHeight() +"\n");
+		sof.append("Image width: " + reader.getFrameWidth() + "\n");
+		sof.append("# of Components: " + reader.getNumOfComponents() + "\n");
+		sof.append("(1 = grey scaled, 3 = color YCbCr or YIQ, 4 = color CMYK)\n");		
+		    
+		for(Component component : reader.getComponents()) {
+			sof.append("\n");
+			sof.append("Component ID: " + component.getId() + "\n");
+			sof.append("Herizontal sampling factor: " + component.getHSampleFactor() + "\n");
+			sof.append("Vertical sampling factor: " + component.getVSampleFactor() + "\n");
+			sof.append("Quantization table #: " + component.getQTableNumber() + "\n");
+			sof.append("DC table number: " + component.getDCTableNumber() + "\n");
+			sof.append("AC table number: " + component.getACTableNumber() + "\n");
+		}
+		
+		sof.append("<= End of SOF information");
+		
+		return sof.toString();
+	}
+	
+	private static void readAPP13(InputStream is, OutputStream os) throws IOException {
+		int length = IOUtils.readUnsignedShortMM(is);
+		byte[] temp = new byte[length - 2];
+		IOUtils.readFully(is, temp);
+	
+		if (new String(temp, 0, PHOTOSHOP_IRB_ID.length()).equals(PHOTOSHOP_IRB_ID)) {
+			os.write(ArrayUtils.subArray(temp, PHOTOSHOP_IRB_ID.length(), temp.length - PHOTOSHOP_IRB_ID.length()));	
+		}
+	}
+	
+	private static void readAPP2(InputStream is, OutputStream os) throws IOException {
+		byte[] icc_profile_buf = new byte[ICC_PROFILE_ID.length()];
+		int length = IOUtils.readUnsignedShortMM(is);
+		IOUtils.readFully(is, icc_profile_buf);
+		// ICC_PROFILE segment.
+		if (Arrays.equals(icc_profile_buf, ICC_PROFILE_ID.getBytes())) {
+			icc_profile_buf = new byte[length - ICC_PROFILE_ID.length() - 2];
+		    IOUtils.readFully(is, icc_profile_buf);
+		    os.write(icc_profile_buf, 2, length - ICC_PROFILE_ID.length() - 4);
+		} else {
+  			IOUtils.skipFully(is, length - ICC_PROFILE_ID.length() - 2);
+  		}
+	}
+	
+	private static void readDHT(InputStream is, List<HTable> m_acTables, List<HTable> m_dcTables) throws IOException {	
+		int len = IOUtils.readUnsignedShortMM(is);
+        byte buf[] = new byte[len - 2];
+        IOUtils.readFully(is, buf);
+		
+		DHTReader reader = new DHTReader(new Segment(Marker.DHT, len, buf));
+		
+		List<HTable> dcTables = reader.getDCTables();
+		List<HTable> acTables = reader.getACTables();
+		
+		m_acTables.addAll(acTables);
+		m_dcTables.addAll(dcTables);
+   	}
+	
+	// Process define Quantization table
+	private static void readDQT(InputStream is, List<QTable> m_qTables) throws IOException {
+		int len = IOUtils.readUnsignedShortMM(is);
+		byte buf[] = new byte[len - 2];
+		IOUtils.readFully(is, buf);
+		
+		DQTReader reader = new DQTReader(new Segment(Marker.DQT, len, buf));
+		List<QTable> qTables = reader.getTables();
+		m_qTables.addAll(qTables);		
+	}
+	
+	public static Map<MetadataType, Metadata> readMetadata(InputStream is) throws IOException {
+		Map<MetadataType, Metadata> metadataMap = new HashMap<MetadataType, Metadata>();
+		Map<String, Thumbnail> thumbnails = new HashMap<String, Thumbnail>();
+		// Need to wrap the input stream with a BufferedInputStream to
+		// speed up reading SOS
+		is = new BufferedInputStream(is);
+		// Definitions
+		List<QTable> m_qTables = new ArrayList<QTable>(4);
+		List<HTable> m_acTables = new ArrayList<HTable>(4);	
+		List<HTable> m_dcTables = new ArrayList<HTable>(4);		
+		// Each SOFReader is associated with a single SOF segment
+		// Usually there is only one SOF segment, but for hierarchical
+		// JPEG, there could be more than one SOF
+		List<SOFReader> readers = new ArrayList<SOFReader>();
+		// Used to read multiple segment ICCProfile
+		ByteArrayOutputStream iccProfileStream = null;
+		// Used to read multiple segment Adobe APP13
+		ByteArrayOutputStream eightBIMStream = null;
+		// Used to read multiple segment XMP
+		byte[] extendedXMP = null;
+		String xmpGUID = ""; // 32 byte ASCII hex string
+		Comments comments = null;
+				
+		List<Segment> appnSegments = new ArrayList<Segment>();
+	
+		boolean finished = false;
+		int length = 0;
+		short marker;
+		Marker emarker;
+		
+		// The very first marker should be the start_of_image marker!	
+		if(Marker.fromShort(IOUtils.readShortMM(is)) != Marker.SOI)
+			throw new IllegalArgumentException("Invalid JPEG image, expected SOI marker not found!");
+		
+		marker = IOUtils.readShortMM(is);
+		
+		while (!finished) {	        
+			if (Marker.fromShort(marker) == Marker.EOI)	{
+				finished = true;
+			} else {// Read markers
+				emarker = Marker.fromShort(marker);
+	
+				switch (emarker) {
+					case APP0:
+					case APP1:
+					case APP2:
+					case APP3:
+					case APP4:
+					case APP5:
+					case APP6:
+					case APP7:
+					case APP8:
+					case APP9:
+					case APP10:
+					case APP11:
+					case APP12:
+					case APP13:
+					case APP14:
+					case APP15:
+						byte[] appBytes = readSegmentData(is);
+						appnSegments.add(new Segment(emarker, appBytes.length + 2, appBytes));
+						marker = IOUtils.readShortMM(is);
+						break;
+					case COM:
+						if(comments == null) comments = new Comments();
+						comments.addComment(readSegmentData(is));
+						marker = IOUtils.readShortMM(is);
+				    	break;				   				
+					case DHT:
+						readDHT(is, m_acTables, m_dcTables);
+						marker = IOUtils.readShortMM(is);
+						break;
+					case DQT:
+						readDQT(is, m_qTables);
+						marker = IOUtils.readShortMM(is);
+						break;
+					case SOF0:
+					case SOF1:
+					case SOF2:
+					case SOF3:
+					case SOF5:
+					case SOF6:
+					case SOF7:
+					case SOF9:
+					case SOF10:
+					case SOF11:
+					case SOF13:
+					case SOF14:
+					case SOF15:
+						readers.add(readSOF(is, emarker));
+						marker = IOUtils.readShortMM(is);
+						break;
+					case SOS:	
+						SOFReader reader = readers.get(readers.size() - 1);
+						marker = readSOS(is, reader);
+						LOGGER.debug("\n{}", sofToString(reader));
+						break;
+					case JPG: // JPG and JPGn shouldn't appear in the image.
+					case JPG0:
+					case JPG13:
+				    case TEM: // The only stand alone mark besides SOI, EOI, and RSTn. 
+						marker = IOUtils.readShortMM(is);
+						break;
+				    case PADDING:
+				    	int nextByte = 0;
+				    	while((nextByte = IOUtils.read(is)) == 0xff) {;}
+				    	marker = (short)((0xff<<8)|nextByte);
+				    	break;
+				    default:
+					    length = IOUtils.readUnsignedShortMM(is);
+					    IOUtils.skipFully(is, length - 2);
+					    marker = IOUtils.readShortMM(is);			    
+				}
+			}
+	    }
+		
+		is.close();
+		
+		// Debugging
+		LOGGER.debug("\n{}", qTablesToString(m_qTables));
+		LOGGER.debug("\n{}", hTablesToString(m_acTables));	
+		LOGGER.debug("\n{}", hTablesToString(m_dcTables));
+			
+		for(Segment segment : appnSegments) {
+			byte[] data = segment.getData();
+			length = segment.getLength();
+			if(segment.getMarker() == Marker.APP0) {
+				if (new String(data, 0, JFIF_ID.length()).equals(JFIF_ID)) {
+					metadataMap.put(MetadataType.JPG_JFIF, new JFIF(ArrayUtils.subArray(data, JFIF_ID.length(), length - JFIF_ID.length() - 2)));
+				}
+			} else if(segment.getMarker() == Marker.APP1) {
+				// Check for EXIF
+				if(new String(data, 0, EXIF_ID.length()).equals(EXIF_ID)) {
+					// We found EXIF
+					JpegExif exif = new JpegExif(ArrayUtils.subArray(data, EXIF_ID.length(), length - EXIF_ID.length() - 2));
+					metadataMap.put(MetadataType.EXIF, exif);
+				} else if(new String(data, 0, XMP_ID.length()).equals(XMP_ID) ||
+						new String(data, 0, NON_STANDARD_XMP_ID.length()).equals(NON_STANDARD_XMP_ID)) {
+					// We found XMP, add it to metadata list (We may later revise it if we have ExtendedXMP)
+					XMP xmp = new JpegXMP(ArrayUtils.subArray(data, XMP_ID.length(), length - XMP_ID.length() - 2));
+					metadataMap.put(MetadataType.XMP, xmp);
+					// Retrieve and remove XMP GUID if available
+					xmpGUID = XMLUtils.getAttribute(xmp.getXmpDocument(), "rdf:Description", "xmpNote:HasExtendedXMP");
+				} else if(new String(data, 0, XMP_EXT_ID.length()).equals(XMP_EXT_ID)) {
+					// We found ExtendedXMP, add the data to ExtendedXMP memory buffer				
+					int i = XMP_EXT_ID.length();
+					// 128-bit MD5 digest of the full ExtendedXMP serialization
+					byte[] guid = ArrayUtils.subArray(data, i, 32);
+					if(Arrays.equals(guid, xmpGUID.getBytes())) { // We have matched the GUID, copy it
+						i += 32;
+						long extendedXMPLength = IOUtils.readUnsignedIntMM(data, i);
+						i += 4;
+						if(extendedXMP == null)
+							extendedXMP = new byte[(int)extendedXMPLength];
+						// Offset for the current segment
+						long offset = IOUtils.readUnsignedIntMM(data, i);
+						i += 4;
+						byte[] xmpBytes = ArrayUtils.subArray(data, i, length - XMP_EXT_ID.length() - 42);
+						System.arraycopy(xmpBytes, 0, extendedXMP, (int)offset, xmpBytes.length);
+					}
+				}
+			} else if(segment.getMarker() == Marker.APP2) {
+				// We're only interested in ICC_Profile
+				if (new String(data, 0, ICC_PROFILE_ID.length()).equals(ICC_PROFILE_ID)) {
+					if(iccProfileStream == null)
+						iccProfileStream = new ByteArrayOutputStream();
+					iccProfileStream.write(ArrayUtils.subArray(data, ICC_PROFILE_ID.length() + 2, length - ICC_PROFILE_ID.length() - 4));
+				}
+			} else if(segment.getMarker() == Marker.APP12) {
+				if (new String(data, 0, DUCKY_ID.length()).equals(DUCKY_ID)) {
+					metadataMap.put(MetadataType.JPG_DUCKY, new Ducky(ArrayUtils.subArray(data, DUCKY_ID.length(), length - DUCKY_ID.length() - 2)));
+				}
+			} else if(segment.getMarker() == Marker.APP13) {
+				if (new String(data, 0, PHOTOSHOP_IRB_ID.length()).equals(PHOTOSHOP_IRB_ID)) {
+					if(eightBIMStream == null)
+						eightBIMStream = new ByteArrayOutputStream();
+					eightBIMStream.write(ArrayUtils.subArray(data, PHOTOSHOP_IRB_ID.length(), length - PHOTOSHOP_IRB_ID.length() - 2));
+				}
+			} else if(segment.getMarker() == Marker.APP14) {
+				if (new String(data, 0, ADOBE_ID.length()).equals(ADOBE_ID)) {
+					metadataMap.put(MetadataType.JPG_ADOBE, new Adobe(ArrayUtils.subArray(data, ADOBE_ID.length(), length - ADOBE_ID.length() - 2)));
+				}
+			}
+		}
+		
+		// Now it's time to join multiple segments ICC_PROFILE and/or XMP		
+		if(iccProfileStream != null) { // We have ICCProfile data
+			ICCProfile icc_profile = new ICCProfile(iccProfileStream.toByteArray());
+			metadataMap.put(MetadataType.ICC_PROFILE, icc_profile);
+		}
+		
+		if(eightBIMStream != null) {
+			IRB irb = new IRB(eightBIMStream.toByteArray());	
+			metadataMap.put(MetadataType.PHOTOSHOP_IRB, irb);
+			_8BIM iptc = irb.get8BIM(ImageResourceID.IPTC_NAA.getValue());
+			// Extract IPTC as stand-alone meta
+			if(iptc != null) {
+				metadataMap.put(MetadataType.IPTC, new IPTC(iptc.getData()));
+			}
+		}
+		
+		if(extendedXMP != null) {
+			XMP xmp = ((XMP)metadataMap.get(MetadataType.XMP));
+			if(xmp != null)
+				xmp.setExtendedXMPData(extendedXMP);
+		}
+		
+		if(comments != null)
+			metadataMap.put(MetadataType.COMMENT, comments);
+			
+		// Extract thumbnails to ImageMetadata
+		Metadata meta = metadataMap.get(MetadataType.EXIF);
+		if(meta != null) {
+			Exif exif = (Exif)meta;
+			if(!exif.isDataRead())
+				exif.read();
+			if(exif.containsThumbnail()) {
+				thumbnails.put("EXIF", exif.getThumbnail());
+			}
+		}
+		
+		meta = metadataMap.get(MetadataType.PHOTOSHOP_IRB);
+		if(meta != null) {
+			IRB irb = (IRB)meta;
+			if(!irb.isDataRead())
+				irb.read();
+			if(irb.containsThumbnail()) {
+				thumbnails.put("PHOTOSHOP_IRB", irb.getThumbnail());
+			}
+		}
+		
+		metadataMap.put(MetadataType.IMAGE, new ImageMetadata(thumbnails));
+		
+		return metadataMap;
+	}
+	
+	private static byte[] readSegmentData(InputStream is) throws IOException {
+		int length = IOUtils.readUnsignedShortMM(is);
+		byte[] data = new byte[length - 2];
+		IOUtils.readFully(is, data);
+		
+		return data;
+	}
+	
+	private static SOFReader readSOF(InputStream is, Marker marker) throws IOException {		
+		int len = IOUtils.readUnsignedShortMM(is);
+		byte buf[] = new byte[len - 2];
+		IOUtils.readFully(is, buf);
+		
+		Segment segment = new Segment(marker, len, buf);		
+		SOFReader reader = new SOFReader(segment);
+		
+		return reader;
+	}	
+	
+	// This method is very slow if not wrapped in some kind of cache stream but it works for multiple
+	// SOSs in case of progressive JPEG
+	private static short readSOS(InputStream is, SOFReader sofReader) throws IOException {
+		int len = IOUtils.readUnsignedShortMM(is);
+		byte buf[] = new byte[len - 2];
+		IOUtils.readFully(is, buf);
+		
+		Segment segment = new Segment(Marker.SOS, len, buf);
+		new SOSReader(segment, sofReader);
+		
+		// Actual image data follow.
+		int nextByte = 0;
+		short marker = 0;	
+		
+		while((nextByte = IOUtils.read(is)) != -1) {
+			if(nextByte == 0xff) {
+				nextByte = IOUtils.read(is);
+				
+				if (nextByte == -1) {
+					throw new IOException("Premature end of SOS segment!");					
+				}								
+				
+				if (nextByte != 0x00) {
+					marker = (short)((0xff<<8)|nextByte);
+					
+					switch (Marker.fromShort(marker)) {										
+						case RST0:  
+						case RST1:
+						case RST2:
+						case RST3:
+						case RST4:
+						case RST5:
+						case RST6:
+						case RST7:
+							continue;
+						default:											
+					}
+					break;
+				}
+			}
+		}
+		
+		if (nextByte == -1) {
+			throw new IOException("Premature end of SOS segment!");
+		}
+
+		return marker;
+	}
+	
+	// Remove APPn segment
+	public static void removeAPPn(Marker APPn, InputStream is, OutputStream os) throws IOException {
+		if(APPn.getValue() < (short)0xffe0 || APPn.getValue() > (short)0xffef)
+			throw new IllegalArgumentException("Input marker is not an APPn marker");		
+		// Flag when we are done
+		boolean finished = false;
+		int length = 0;	
+		short marker;
+		Marker emarker;
+				
+		// The very first marker should be the start_of_image marker!	
+		if(Marker.fromShort(IOUtils.readShortMM(is)) != Marker.SOI)
+			throw new IOException("Invalid JPEG image, expected SOI marker not found!");
+
+		IOUtils.writeShortMM(os, Marker.SOI.getValue());
+		
+		marker = IOUtils.readShortMM(is);
+		
+		while (!finished) {	        
+			if (Marker.fromShort(marker) == Marker.EOI)	{
+				IOUtils.writeShortMM(os, Marker.EOI.getValue());
+				finished = true;
+			} else { // Read markers
+				emarker = Marker.fromShort(marker);
+		
+				switch (emarker) {
+					case JPG: // JPG and JPGn shouldn't appear in the image.
+					case JPG0:
+					case JPG13:
+				    case TEM: // The only stand alone marker besides SOI, EOI, and RSTn. 
+						IOUtils.writeShortMM(os, marker);
+				    	marker = IOUtils.readShortMM(is);
+						break;
+				    case PADDING:	
+				    	IOUtils.writeShortMM(os, marker);
+				    	int nextByte = 0;
+				    	while((nextByte = IOUtils.read(is)) == 0xff) {
+				    		IOUtils.write(os, nextByte);
+				    	}
+				    	marker = (short)((0xff<<8)|nextByte);
+				    	break;				
+				    case SOS:	
+				    	IOUtils.writeShortMM(os, marker);
+						// use copyToEnd instead for multiple SOS
+				    	//marker = copySOS(is, os);
+				    	copyToEnd(is, os);
+						finished = true; 
+						break;
+				    default:
+					    length = IOUtils.readUnsignedShortMM(is);					
+					    byte[] buf = new byte[length - 2];
+					    IOUtils.readFully(is, buf);
+					    
+					    if(emarker != APPn) { // Copy the data
+					    	IOUtils.writeShortMM(os, marker);
+					    	IOUtils.writeShortMM(os, (short)length);
+					    	IOUtils.write(os, buf);
+					    }
+					    
+					    marker = IOUtils.readShortMM(is);
+				}
+			}
+	    }
+	}
+	
+	/**
+	 * Removes metadata specified by the input MetadataType set.
+	 * 
+	 * @param metadataTypes a set containing all the MetadataTypes to be removed.
+	 * @param is InputStream for the original image.
+	 * @param os OutputStream for the image with metadata removed.
+	 * @throws IOException
+	 * @return A map of the removed metadata
+	 */	
+	public static Map<MetadataType, Metadata> removeMetadata(InputStream is, OutputStream os, MetadataType ... metadataTypes) throws IOException {
+		return removeMetadata(new HashSet<MetadataType>(Arrays.asList(metadataTypes)), is, os);
+	}
+	
+	/**
+	 * Removes metadata specified by the input MetadataType set.
+	 * 
+	 * @param metadataTypes a set containing all the MetadataTypes to be removed.
+	 * @param is InputStream for the original image.
+	 * @param os OutputStream for the image with metadata removed.
+	 * @throws IOException
+	 * @return A map of the removed metadata
+	 */
+	public static Map<MetadataType, Metadata> removeMetadata(Set<MetadataType> metadataTypes, InputStream is, OutputStream os) throws IOException {
+		// Create a map to hold all the metadata and thumbnails
+		Map<MetadataType, Metadata> metadataMap = new HashMap<MetadataType, Metadata>();
+		// In case IRB data are partially removed, we keep removed metadata here
+		Map<MetadataType, Metadata> extraMetadataMap = new HashMap<MetadataType, Metadata>();
+	
+		Comments comments = null;
+		
+		List<Segment> appnSegments = new ArrayList<Segment>();			
+		
+		// Flag when we are done
+		boolean finished = false;
+		int length = 0;
+		short marker;
+		Marker emarker;
+
+		// The very first marker should be the start_of_image marker!
+		if (Marker.fromShort(IOUtils.readShortMM(is)) != Marker.SOI)
+			throw new IOException("Invalid JPEG image, expected SOI marker not found!");
+			
+		IOUtils.writeShortMM(os, Marker.SOI.getValue());
+
+		marker = IOUtils.readShortMM(is);
+
+		while (!finished) {
+			if (Marker.fromShort(marker) == Marker.EOI) {
+				IOUtils.writeShortMM(os, Marker.EOI.getValue());
+				finished = true;
+			} else { // Read markers
+				emarker = Marker.fromShort(marker);
+		
+				switch (emarker) {
+					case JPG: // JPG and JPGn shouldn't appear in the image.
+					case JPG0:
+					case JPG13:
+					case TEM: // The only stand alone marker besides SOI, EOI, and RSTn.
+						IOUtils.writeShortMM(os, marker);
+						marker = IOUtils.readShortMM(is);
+						break;
+					case PADDING:
+						IOUtils.writeShortMM(os, marker);
+						int nextByte = 0;
+						while ((nextByte = IOUtils.read(is)) == 0xff) {
+							IOUtils.write(os, nextByte);
+						}
+						marker = (short) ((0xff << 8) | nextByte);
+						break;
+					case SOS: // There should be no meta data after this segment
+						IOUtils.writeShortMM(os, marker);
+						copyToEnd(is, os);
+						finished = true;
+						break;
+					case COM:
+						if(metadataTypes.contains(MetadataType.COMMENT)) {
+							if(comments == null) comments = new Comments();
+							comments.addComment(readSegmentData(is));
+						 	marker = IOUtils.readShortMM(is);
+						} else
+							marker = copySegment(marker, is, os);
+						break;						
+					case APP0:
+						if(metadataTypes.contains(MetadataType.JPG_JFIF)) {
+							length = IOUtils.readUnsignedShortMM(is);
+						    byte[] temp = new byte[length - 2];
+						    IOUtils.readFully(is, temp);
+							// Not JFIF segment
+							if (temp.length < JFIF_ID.length() || ! JFIF_ID.equals(new String(temp, 0, JFIF_ID.length()))) {
+								IOUtils.writeShortMM(os, marker);
+								IOUtils.writeShortMM(os, (short) length);
+								IOUtils.write(os, temp);
+							} else { // We put it into the Segment map for further use
+								appnSegments.add(new Segment(emarker, length, temp));
+							}
+							marker = IOUtils.readShortMM(is);
+						} else
+							marker = copySegment(marker, is, os);
+						break;
+					case APP1:
+						// We are only interested in EXIF and XMP
+						if(metadataTypes.contains(MetadataType.EXIF) || metadataTypes.contains(MetadataType.XMP)) {
+							length = IOUtils.readUnsignedShortMM(is);
+							byte[] temp = new byte[length - 2];
+							IOUtils.readFully(is, temp);
+							// XMP or EXIF segment
+							if((metadataTypes.contains(MetadataType.XMP) && temp.length >= XMP_EXT_ID.length() && new String(temp, 0, XMP_EXT_ID.length()).equals(XMP_EXT_ID))
+								|| (metadataTypes.contains(MetadataType.XMP) && temp.length >= XMP_ID.length() && new String(temp, 0, XMP_ID.length()).equals(XMP_ID))
+								|| (metadataTypes.contains(MetadataType.XMP) && temp.length >= NON_STANDARD_XMP_ID.length() && new String(temp, 0, NON_STANDARD_XMP_ID.length()).equals(NON_STANDARD_XMP_ID))
+								|| (metadataTypes.contains(MetadataType.EXIF) && temp.length >= EXIF_ID.length() && new String(temp, 0, EXIF_ID.length()).equals(EXIF_ID))) { // EXIF
+								// We put it into the Segment map for further use
+								appnSegments.add(new Segment(emarker, length, temp));
+							} else { // We don't want to remove any of them
+								IOUtils.writeShortMM(os, marker);
+								IOUtils.writeShortMM(os, (short) length);
+								IOUtils.write(os, temp);
+							}
+							marker = IOUtils.readShortMM(is);
+						} else
+							marker = copySegment(marker, is, os);
+						break;
+					case APP2:
+						if(metadataTypes.contains(MetadataType.ICC_PROFILE)) {
+							length = IOUtils.readUnsignedShortMM(is);
+							byte[] temp = new byte[length - 2];
+							IOUtils.readFully(is, temp);	
+							// Not ICC_Profile segment
+							if (temp.length < ICC_PROFILE_ID.length() || ! ICC_PROFILE_ID.equals(new String(temp, 0, ICC_PROFILE_ID.length()))) {
+								IOUtils.writeShortMM(os, marker);
+								IOUtils.writeShortMM(os, (short) length);
+								IOUtils.write(os, temp);
+							} else { // We put it into the Segment map for further use
+								appnSegments.add(new Segment(emarker, length, temp));
+							}
+							marker = IOUtils.readShortMM(is);
+						} else
+							marker = copySegment(marker, is, os);
+						break;
+					case APP12:
+						if(metadataTypes.contains(MetadataType.JPG_DUCKY)) {
+							length = IOUtils.readUnsignedShortMM(is);
+							byte[] temp = new byte[length - 2];
+							IOUtils.readFully(is, temp);	
+							// Not Ducky segment
+							if (temp.length < DUCKY_ID.length() || ! DUCKY_ID.equals(new String(temp, 0, DUCKY_ID.length()))) {
+								IOUtils.writeShortMM(os, marker);
+								IOUtils.writeShortMM(os, (short) length);
+								IOUtils.write(os, temp);
+							} else { // We put it into the Segment map for further use
+								appnSegments.add(new Segment(emarker, length, temp));
+							}
+							marker = IOUtils.readShortMM(is);
+						} else
+							marker = copySegment(marker, is, os);
+						break;
+					case APP13:
+						if(metadataTypes.contains(MetadataType.PHOTOSHOP_IRB) || metadataTypes.contains(MetadataType.IPTC)
+							|| metadataTypes.contains(MetadataType.XMP) || metadataTypes.contains(MetadataType.EXIF)) {
+							length = IOUtils.readUnsignedShortMM(is);
+							byte[] temp = new byte[length - 2];
+							IOUtils.readFully(is, temp);	
+							// PHOTOSHOP IRB segment
+							if (temp.length >= PHOTOSHOP_IRB_ID.length() && new String(temp, 0, PHOTOSHOP_IRB_ID.length()).equals(PHOTOSHOP_IRB_ID)) {
+								IRB irb = new IRB(ArrayUtils.subArray(temp, PHOTOSHOP_IRB_ID.length(), temp.length - PHOTOSHOP_IRB_ID.length()));
+								// Shallow copy the map.
+								Map<Short, _8BIM> bimMap = new HashMap<Short, _8BIM>(irb.get8BIM());								
+								if(!metadataTypes.contains(MetadataType.PHOTOSHOP_IRB)) {
+									if(metadataTypes.contains(MetadataType.IPTC)) {
+										// We only remove IPTC_NAA and keep the other IRB data untouched.
+										_8BIM bim = bimMap.remove(ImageResourceID.IPTC_NAA.getValue());
+										if(bim != null) extraMetadataMap.put(MetadataType.IPTC, new IPTC(bim.getData()));
+									} 
+									if(metadataTypes.contains(MetadataType.XMP)) {
+										// We only remove XMP and keep the other IRB data untouched.
+										_8BIM bim = bimMap.remove(ImageResourceID.XMP_METADATA.getValue());
+										if(bim != null) extraMetadataMap.put(MetadataType.XMP, new JpegXMP(bim.getData()));
+									} 
+									if(metadataTypes.contains(MetadataType.EXIF)) {
+										// We only remove EXIF and keep the other IRB data untouched.
+										_8BIM bim = bimMap.remove(ImageResourceID.EXIF_DATA1.getValue());
+										if(bim != null) extraMetadataMap.put(MetadataType.EXIF, new JpegExif(bim.getData()));
+										// I can't find more information on this one, so remove it just in case.
+										bimMap.remove(ImageResourceID.EXIF_DATA3.getValue());
+									}
+									// Write back the IRB
+									writeIRB(os, bimMap.values());
+								} else { // We put it into the Segment map for further use
+									appnSegments.add(new Segment(emarker, length, temp));
+								}							
+							} else {
+								IOUtils.writeShortMM(os, marker);
+								IOUtils.writeShortMM(os, (short) length);
+								IOUtils.write(os, temp);
+							}
+							marker = IOUtils.readShortMM(is);
+						} else
+							marker = copySegment(marker, is, os);
+						break;
+					case APP14:
+						if(metadataTypes.contains(MetadataType.JPG_ADOBE)) {
+							length = IOUtils.readUnsignedShortMM(is);
+							byte[] temp = new byte[length - 2];
+							IOUtils.readFully(is, temp);	
+							// Not Adobe segment
+							if (temp.length < ADOBE_ID.length() || ! ADOBE_ID.equals(new String(temp, 0, ADOBE_ID.length()))) {
+								IOUtils.writeShortMM(os, marker);
+								IOUtils.writeShortMM(os, (short) length);
+								IOUtils.write(os, temp);
+							} else { // We put it into the Segment map for further use
+								appnSegments.add(new Segment(emarker, length, temp));
+							}
+							marker = IOUtils.readShortMM(is);
+						} else
+							marker = copySegment(marker, is, os);
+						break;
+					default:
+						marker = copySegment(marker, is, os);
+				}
+			}
+		}
+		
+		extractMetadataFromAPPn(appnSegments, metadataMap);
+		
+		// If we are supposed to remove IPTC, check if we have removed it from IRB. If yes, add it
+		// to the removed map 
+		if(metadataTypes.contains(MetadataType.IPTC) && metadataMap.get(MetadataType.IPTC) == null) {
+			Metadata meta = extraMetadataMap.get(MetadataType.IPTC);
+			if(meta != null) metadataMap.put(MetadataType.IPTC, meta);
+		}
+		// If we are supposed to remove XMP, check if we have removed it from IRB. If yes, add it
+		// to the removed map 
+		if(metadataTypes.contains(MetadataType.XMP) && metadataMap.get(MetadataType.XMP) == null) {
+			Metadata meta = extraMetadataMap.get(MetadataType.XMP);
+			if(meta != null) metadataMap.put(MetadataType.XMP, meta);
+		}
+		// If we are supposed to remove EXIF, check if we have removed it from IRB. If yes, add it
+		// to the removed map 
+		if(metadataTypes.contains(MetadataType.EXIF) && metadataMap.get(MetadataType.EXIF) == null) {
+			Metadata meta = extraMetadataMap.get(MetadataType.EXIF);
+			if(meta != null) metadataMap.put(MetadataType.EXIF, meta);
+		}
+		
+		if(comments != null)
+			metadataMap.put(MetadataType.COMMENT, comments);
+		
+		return metadataMap;
+	}
+	
+	@SuppressWarnings("unused")
+	private static short skipSOS(InputStream is) throws IOException {
+		int nextByte = 0;
+		short marker = 0;	
+		
+		while((nextByte = IOUtils.read(is)) != -1) {
+			if(nextByte == 0xff) {
+				nextByte = IOUtils.read(is);
+						
+				if (nextByte == -1) {
+					throw new IOException("Premature end of SOS segment!");					
+				}								
+				
+				if (nextByte != 0x00) { // This is a marker
+					marker = (short)((0xff<<8)|nextByte);
+					
+					switch (Marker.fromShort(marker)) {										
+						case RST0:  
+						case RST1:
+						case RST2:
+						case RST3:
+						case RST4:
+						case RST5:
+						case RST6:
+						case RST7:
+							continue;
+						default:											
+					}
+					break;
+				}
+			}
+		}
+		
+		if (nextByte == -1) {
+			throw new IOException("Premature end of SOS segment!");
+		}
+
+		return marker;
+	}
+	
+	private static void writeComment(String comment, OutputStream os) throws IOException	{
+		new COMBuilder().comment(comment).build().write(os);
+	}
+	
+	/**
+	 * Write ICC_Profile as one or more APP2 segments
+	 * <p>
+	 * Due to the JPEG segment length limit, we have
+	 * to split ICC_Profile data and put them into 
+	 * different APP2 segments if the data can not fit
+	 * into one segment.
+	 * 
+	 * @param os output stream to write the ICC_Profile
+	 * @param data ICC_Profile data
+	 * @throws IOException
+	 */
+	private static void writeICCProfile(OutputStream os, byte[] data) throws IOException {
+		// ICC_Profile ID
+		int maxSegmentLen = 65535;
+		int maxICCDataLen = 65519;
+		int numOfSegment = data.length/maxICCDataLen;
+		int leftOver = data.length%maxICCDataLen;
+		int totalSegment = (numOfSegment == 0)? 1: ((leftOver == 0)? numOfSegment: (numOfSegment + 1));
+		for(int i = 0; i < numOfSegment; i++) {
+			IOUtils.writeShortMM(os, Marker.APP2.getValue());
+			IOUtils.writeShortMM(os, maxSegmentLen);
+			IOUtils.write(os, ICC_PROFILE_ID.getBytes());
+			IOUtils.writeShortMM(os, totalSegment|(i+1)<<8);
+			IOUtils.write(os, data, i*maxICCDataLen, maxICCDataLen);
+		}
+		if(leftOver != 0) {
+			IOUtils.writeShortMM(os, Marker.APP2.getValue());
+			IOUtils.writeShortMM(os, leftOver + 16);
+			IOUtils.write(os, ICC_PROFILE_ID.getBytes());
+			IOUtils.writeShortMM(os, totalSegment|totalSegment<<8);
+			IOUtils.write(os, data, data.length - leftOver, leftOver);
+		}
+	}	
+	
+	private static void writeIRB(OutputStream os, _8BIM ... bims) throws IOException {
+		if(bims != null && bims.length > 0)
+			writeIRB(os, Arrays.asList(bims));
+	}
+	
+	private static void writeIRB(OutputStream os, Collection<_8BIM> bims) throws IOException {
+		if(bims != null && bims.size() > 0) {
+			IOUtils.writeShortMM(os, Marker.APP13.getValue());
+	    	ByteArrayOutputStream bout = new ByteArrayOutputStream();
+			for(_8BIM bim : bims)
+				bim.write(bout);
+			// Write segment length
+			IOUtils.writeShortMM(os, 14 + 2 +  bout.size());
+			// Write segment data
+			os.write(PHOTOSHOP_IRB_ID.getBytes());
+			os.write(bout.toByteArray());
+		}
+	}
+	
+	// Prevent from instantiation
+	private JPGMeta() {}
+}
diff --git a/src/pixy/meta/jpeg/JpegExif.java b/src/pixy/meta/jpeg/JpegExif.java
new file mode 100644
index 0000000..871b385
--- /dev/null
+++ b/src/pixy/meta/jpeg/JpegExif.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * JpegExif.java
+ *
+ * Who   Date       Description
+ * ====  =======    =================================================
+ * WY    03Feb2015  Initial creation
+ */
+
+package pixy.meta.jpeg;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+import pixy.image.jpeg.Marker;
+import pixy.image.tiff.ASCIIField;
+import pixy.image.tiff.IFD;
+import pixy.image.tiff.LongField;
+import pixy.image.tiff.TiffField;
+import pixy.image.tiff.TiffTag;
+import pixy.io.IOUtils;
+import pixy.io.MemoryCacheRandomAccessOutputStream;
+import pixy.io.RandomAccessOutputStream;
+import pixy.io.WriteStrategyMM;
+import pixy.io.WriteStrategyII;
+import pixy.meta.exif.Exif;
+import pixy.meta.exif.ExifTag;
+
+public class JpegExif extends Exif {
+
+	public JpegExif() {
+		;
+	}
+	
+	public JpegExif(byte[] data) {
+		super(data);
+	}
+	
+	private void createImageIFD() {
+		// Create Image IFD (IFD0)
+		imageIFD = new IFD();
+		TiffField<?> tiffField = new ASCIIField(TiffTag.IMAGE_DESCRIPTION.getValue(), "Exif created by JPEGTweaker");
+		imageIFD.addField(tiffField);
+		String softWare = "JPEGTweaker 1.0";
+		tiffField = new ASCIIField(TiffTag.SOFTWARE.getValue(), softWare);
+		imageIFD.addField(tiffField);
+		DateFormat formatter = new SimpleDateFormat("yyyy:MM:dd HH:mm:ss");
+		tiffField = new ASCIIField(TiffTag.DATETIME.getValue(), formatter.format(new Date()));
+		imageIFD.addField(tiffField);		
+	}
+	
+	/** 
+	 * Write the EXIF data to the OutputStream
+	 * 
+	 * @param os OutputStream
+	 * @throws Exception 
+	 */
+	@Override
+	public void write(OutputStream os) throws IOException {
+		ensureDataRead();
+		// Wraps output stream with a RandomAccessOutputStream
+		RandomAccessOutputStream randOS = new MemoryCacheRandomAccessOutputStream(os);
+		// Write JPEG the EXIF data
+		// Writes APP1 marker
+		IOUtils.writeShortMM(os, Marker.APP1.getValue());		
+		// TIFF structure starts here
+		short tiffID = 0x2a; //'*'
+		randOS.setWriteStrategy((preferredEndian == IOUtils.BIG_ENDIAN)? WriteStrategyMM.getInstance():WriteStrategyII.getInstance());
+		randOS.writeShort(preferredEndian);
+		randOS.writeShort(tiffID);
+		// First IFD offset relative to TIFF structure
+		randOS.seek(0x04);
+		randOS.writeInt(FIRST_IFD_OFFSET);
+		// Writes IFDs
+		randOS.seek(FIRST_IFD_OFFSET);
+		if(imageIFD == null) createImageIFD();
+		// Attach EXIIF and/or GPS SubIFD to main image IFD
+		if(exifSubIFD != null) {
+			imageIFD.addField(new LongField(TiffTag.EXIF_SUB_IFD.getValue(), new int[]{0})); // Place holder
+			imageIFD.addChild(TiffTag.EXIF_SUB_IFD, exifSubIFD);
+			if(interopSubIFD != null) {
+				exifSubIFD.addField(new LongField(ExifTag.EXIF_INTEROPERABILITY_OFFSET.getValue(), new int[]{0})); // Place holder
+				exifSubIFD.addChild(ExifTag.EXIF_INTEROPERABILITY_OFFSET, interopSubIFD);			
+			}
+		}
+		if(gpsSubIFD != null) {
+			imageIFD.addField(new LongField(TiffTag.GPS_SUB_IFD.getValue(), new int[]{0})); // Place holder
+			imageIFD.addChild(TiffTag.GPS_SUB_IFD, gpsSubIFD);
+		}
+		int offset = imageIFD.write(randOS, FIRST_IFD_OFFSET);
+		if(thumbnail != null && thumbnail.containsImage()) {
+			imageIFD.setNextIFDOffset(randOS, offset);
+			randOS.seek(offset); // Set the stream pointer to the correct position
+			thumbnail.write(randOS);
+		}
+		// Now it's time to update the segment length
+		int length = (int)randOS.getLength();
+		// Update segment length
+		IOUtils.writeShortMM(os, length + 8);
+		// Add EXIF identifier with trailing bytes [0x00,0x00].
+		byte[] exif = {0x45, 0x78, 0x69, 0x66, 0x00, 0x00};
+		IOUtils.write(os, exif);
+		// Dump randOS to normal output stream and we are done!
+		randOS.seek(0);
+		randOS.writeToStream(length);
+		randOS.shallowClose();
+	}
+}
diff --git a/src/pixy/meta/jpeg/JpegXMP.java b/src/pixy/meta/jpeg/JpegXMP.java
new file mode 100644
index 0000000..4485f16
--- /dev/null
+++ b/src/pixy/meta/jpeg/JpegXMP.java
@@ -0,0 +1,106 @@
+package pixy.meta.jpeg;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+import pixy.image.jpeg.Marker;
+import pixy.io.IOUtils;
+import pixy.meta.xmp.XMP;
+import pixy.string.StringUtils;
+import pixy.string.XMLUtils;
+import pixy.util.ArrayUtils;
+
+import static pixy.image.jpeg.JPGConsts.*;
+
+public class JpegXMP extends XMP {
+
+	// Largest size for each extended XMP chunk
+	private static final int MAX_EXTENDED_XMP_CHUNK_SIZE = 65458;
+	private static final int MAX_XMP_CHUNK_SIZE = 65504;
+	private static final int GUID_LEN = 32;
+		
+	public JpegXMP(byte[] data) {
+		super(data);
+	}
+	
+	public JpegXMP(String xmp) {
+		super(xmp);
+	}
+	
+	/**
+	 * @param xmp XML string for the XMP - Assuming in UTF-8 format.
+	 * @param extendedXmp XML string for the extended XMP - Assuming in UTF-8 format
+	 */
+	public JpegXMP(String xmp, String extendedXmp) {
+		super(xmp, extendedXmp);
+	}
+
+	@Override
+	public void write(OutputStream os) throws IOException {
+		// Add packet wrapper to the XMP document
+		// Add PI at the beginning and end of the document, we will support only UTF-8, no BOM
+		Document xmpDoc = getXmpDocument();
+		XMLUtils.insertLeadingPI(xmpDoc, "xpacket", "begin='' id='W5M0MpCehiHzreSzNTczkc9d'");
+		XMLUtils.insertTrailingPI(xmpDoc, "xpacket", "end='r'");
+		byte[] extendedXmp = getExtendedXmpData();
+		String guid = null;
+		if(extendedXmp != null) { // We have ExtendedXMP
+			guid = StringUtils.generateMD5(extendedXmp);
+			NodeList descriptions = xmpDoc.getElementsByTagName("rdf:Description");
+			int length = descriptions.getLength();
+			if(length > 0) {
+				Element node = (Element)descriptions.item(length - 1);
+				node.setAttribute("xmlns:xmpNote", "http://ns.adobe.com/xmp/extension/");
+				node.setAttribute("xmpNote:HasExtendedXMP", guid);
+			}
+		}
+		// Serialize XMP to byte array
+		byte[] xmp = XMLUtils.serializeToByteArray(xmpDoc);
+		if(xmp.length > MAX_XMP_CHUNK_SIZE)
+			throw new RuntimeException("XMP data size exceededs JPEG segment size");
+		// Write XMP segment
+		IOUtils.writeShortMM(os, Marker.APP1.getValue());
+		// Write segment length
+		IOUtils.writeShortMM(os, XMP_ID.length() + 2 + xmp.length);
+		// Write segment data
+		os.write(XMP_ID.getBytes());
+		os.write(xmp);
+		// Write ExtendedXMP if we have
+		if(extendedXmp != null) {
+			int numOfChunks = extendedXmp.length / MAX_EXTENDED_XMP_CHUNK_SIZE;
+			int extendedXmpLen = extendedXmp.length;
+			int offset = 0;
+			
+			for(int i = 0; i < numOfChunks; i++) {
+				IOUtils.writeShortMM(os, Marker.APP1.getValue());
+				// Write segment length
+				IOUtils.writeShortMM(os, 2 + XMP_EXT_ID.length() + GUID_LEN + 4 + 4 + MAX_EXTENDED_XMP_CHUNK_SIZE);
+				// Write segment data
+				os.write(XMP_EXT_ID.getBytes());
+				os.write(guid.getBytes());
+				IOUtils.writeIntMM(os, extendedXmpLen);
+				IOUtils.writeIntMM(os, offset);
+				os.write(ArrayUtils.subArray(extendedXmp, offset, MAX_EXTENDED_XMP_CHUNK_SIZE));
+				offset += MAX_EXTENDED_XMP_CHUNK_SIZE;			
+			}
+			
+			int leftOver = extendedXmp.length % MAX_EXTENDED_XMP_CHUNK_SIZE;
+			
+			if(leftOver != 0) {
+				IOUtils.writeShortMM(os, Marker.APP1.getValue());
+				// Write segment length
+				IOUtils.writeShortMM(os, 2 + XMP_EXT_ID.length() + GUID_LEN + 4 + 4 + leftOver);
+				// Write segment data
+				os.write(XMP_EXT_ID.getBytes());
+				os.write(guid.getBytes());
+				IOUtils.writeIntMM(os, extendedXmpLen);
+				IOUtils.writeIntMM(os, offset);
+				os.write(ArrayUtils.subArray(extendedXmp, offset, leftOver));
+			}
+		}
+	}
+}
diff --git a/src/pixy/meta/png/PNGMeta.java b/src/pixy/meta/png/PNGMeta.java
new file mode 100644
index 0000000..3401037
--- /dev/null
+++ b/src/pixy/meta/png/PNGMeta.java
@@ -0,0 +1,312 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * PNGMeta.java
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    30Mar2016  Added insertTextChunk()
+ * WY    30Mar2016  Changed XMP trailing pi to "end='r'"
+ * WY    06Jul2015  Added insertXMP(InputSream, OutputStream, XMP)
+ * WY    30Mar2015  Added insertICCProfile()
+ * WY    13Mar2015  Initial creation
+ */
+
+package pixy.meta.png;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.ArrayList;
+import java.util.ListIterator;
+import java.util.Map;
+import java.util.Set;
+import java.util.zip.InflaterInputStream;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.w3c.dom.Document;
+
+import pixy.image.png.ICCPBuilder;
+import pixy.meta.Metadata;
+import pixy.meta.MetadataType;
+import pixy.meta.icc.ICCProfile;
+import pixy.meta.xmp.XMP;
+import pixy.image.png.Chunk;
+import pixy.image.png.ChunkType;
+import pixy.image.png.TextBuilder;
+import pixy.image.png.TextReader;
+import pixy.image.png.UnknownChunk;
+import pixy.io.IOUtils;
+import pixy.string.XMLUtils;
+/**
+ * PNG image tweaking tool
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 10/18/2012
+ */
+public class PNGMeta {
+	
+	/** PNG signature constant */
+    private static final long SIGNATURE = 0x89504E470D0A1A0AL;
+    
+    // Obtain a logger instance
+ 	private static final Logger LOGGER = LoggerFactory.getLogger(PNGMeta.class);
+	
+   	public static void insertChunk(Chunk customChunk, InputStream is, OutputStream os) throws IOException {
+  		insertChunks(is, os, customChunk);
+  	}
+  	
+  	public static void insertChunks(InputStream is, OutputStream os, Chunk... chunks) throws IOException {
+  		List<Chunk> list = readChunks(is);  		
+        Collections.addAll(list, chunks);
+    	
+  		IOUtils.writeLongMM(os, SIGNATURE);
+	
+        serializeChunks(list, os);
+  	}
+  	
+  	public static void insertChunks(List<Chunk> chunks, InputStream is, OutputStream os) throws IOException {
+  		List<Chunk> list = readChunks(is);  		
+        list.addAll(chunks);
+    	
+  		IOUtils.writeLongMM(os, SIGNATURE);
+	
+        serializeChunks(list, os);
+  	}
+  	
+  	public static void insertComments(InputStream is, OutputStream os, List<String> comments) throws IOException {
+  		// Build tEXt chunk
+  		TextBuilder txtBuilder = new TextBuilder(ChunkType.TEXT);
+  		int numOfComments = comments.size();
+  		Chunk[] chunks = new Chunk[numOfComments];
+  		for(int i = 0; i < numOfComments; i++) {
+  			chunks[i] = txtBuilder.keyword("Comment").text(comments.get(i)).build();
+  		}
+  		insertChunks(is, os, chunks);
+  	}
+  	
+  	public static void insertICCProfile(String profile_name, byte[] icc_profile, InputStream is, OutputStream os) throws IOException {
+  		ICCPBuilder builder = new ICCPBuilder();
+  		builder.name(profile_name);
+  		builder.data(icc_profile);
+  		insertChunk(builder.build(), is, os);
+  	}
+  	
+  	public static void insertTextChunk(ChunkType type, String keyword, String text, InputStream is, OutputStream os) throws IOException {
+  		if(type == null || keyword == null || text == null)
+  			throw new IllegalArgumentException("Argument(s) are null");
+  		
+  		insertChunk(new TextBuilder(type).keyword(keyword).text(text).build(), is, os);
+  	}
+  	
+  	public static void insertTextChunks(TextualChunks textualChunks, InputStream is, OutputStream os) throws IOException {
+  		if(textualChunks == null) throw new IllegalArgumentException("Argument is null");
+  		insertChunks(textualChunks.getChunks(), is, os);
+  	}
+  	
+	public static void insertXMP(InputStream is, OutputStream os, XMP xmp) throws IOException {
+  		insert(is, os, XMLUtils.serializeToString(xmp.getMergedDocument()));
+  	}
+  	
+  	// Add leading and trailing PI
+  	public static void insertXMP(InputStream is, OutputStream os, String xmp) throws IOException {
+  		Document doc = XMLUtils.createXML(xmp);
+		XMLUtils.insertLeadingPI(doc, "xpacket", "begin='' id='W5M0MpCehiHzreSzNTczkc9d'");
+		XMLUtils.insertTrailingPI(doc, "xpacket", "end='r'");
+		String newXmp = XMLUtils.serializeToString(doc); // DONOT use XMLUtils.serializeToStringLS()
+  		insert(is, os, newXmp);
+    }
+  	
+  	private static void insert(InputStream is, OutputStream os, String xmp) throws IOException {
+  		// Read all the chunks first
+  		List<Chunk> chunks = readChunks(is);
+	    ListIterator<Chunk> itr = chunks.listIterator();
+	    
+	    // Remove old XMP chunk
+	    while(itr.hasNext()) {
+	    	Chunk chunk = itr.next();
+	    	if(chunk.getChunkType() == ChunkType.ITXT) {
+	    		TextReader reader = new TextReader(chunk);
+				if(reader.getKeyword().equals("XML:com.adobe.xmp")); // We found XMP data
+					itr.remove();
+	    	}
+	    }
+	    
+	    // Create XMP textual chunk
+		Chunk xmpChunk = new TextBuilder(ChunkType.ITXT).keyword("XML:com.adobe.xmp").text(xmp).build();
+		// Insert XMP textual chunk into image
+	    chunks.add(xmpChunk);
+	    
+	    IOUtils.writeLongMM(os, SIGNATURE);
+	    
+        serializeChunks(chunks, os);
+    }
+  	
+   	public static List<Chunk> readChunks(InputStream is) throws IOException {  		
+  		List<Chunk> list = new ArrayList<Chunk>();
+ 		 //Local variables for reading chunks
+        int data_len = 0;
+        int chunk_type = 0;
+        byte[] buf = null;
+     
+        long signature = IOUtils.readLongMM(is);
+
+        if (signature != SIGNATURE) {
+       	 	throw new RuntimeException("Invalid PNG signature");
+        }   
+
+        /** Read header */
+        /** We are expecting IHDR */
+        if ((IOUtils.readIntMM(is)!=13)||(IOUtils.readIntMM(is) != ChunkType.IHDR.getValue())) {
+            throw new RuntimeException("Invalid PNG header");
+        }     
+        
+        buf = new byte[13];
+        IOUtils.readFully(is, buf, 0, 13);
+  
+        list.add(new Chunk(ChunkType.IHDR, 13, buf, IOUtils.readUnsignedIntMM(is)));         
+      
+        while (true) {
+        	data_len = IOUtils.readIntMM(is);
+	       	chunk_type = IOUtils.readIntMM(is);
+	   
+	       	if (chunk_type == ChunkType.IEND.getValue()) {
+	        	 list.add(new Chunk(ChunkType.IEND, data_len, new byte[0], IOUtils.readUnsignedIntMM(is)));
+	       		 break;
+	       	} 
+       		ChunkType chunkType = ChunkType.fromInt(chunk_type);
+       		buf = new byte[data_len];
+       		IOUtils.readFully(is, buf,0, data_len);
+              
+       		if (chunkType == ChunkType.UNKNOWN)
+       			list.add(new UnknownChunk(data_len, chunk_type, buf, IOUtils.readUnsignedIntMM(is)));
+       		else
+       			list.add(new Chunk(chunkType, data_len, buf, IOUtils.readUnsignedIntMM(is)));
+        }
+        
+        return list;
+  	}
+   	
+	private static byte[] readICCProfile(byte[] buf) throws IOException {
+		int profileName_len = 0;
+		while(buf[profileName_len] != 0) profileName_len++;
+		String profileName = new String(buf, 0, profileName_len, "UTF-8");
+		
+		InflaterInputStream ii = new InflaterInputStream(new ByteArrayInputStream(buf, profileName_len + 2, buf.length - profileName_len - 2));
+		LOGGER.info("ICCProfile name: {}", profileName);
+		 
+		byte[] icc_profile = IOUtils.readFully(ii, 4096);
+		LOGGER.info("ICCProfile length: {}", icc_profile.length);
+	 		 
+		return icc_profile;
+ 	}
+  	
+	public static Map<MetadataType, Metadata> readMetadata(InputStream is) throws IOException {
+		Map<MetadataType, Metadata> metadataMap = new HashMap<MetadataType, Metadata>();
+		List<Chunk> chunks = readChunks(is);
+		Iterator<Chunk> iter = chunks.iterator();
+		TextualChunks textualChunk = null;
+		while (iter.hasNext()) {
+			Chunk chunk = iter.next();
+			ChunkType type = chunk.getChunkType();
+			long length = chunk.getLength();
+			if(type == ChunkType.ICCP)
+				metadataMap.put(MetadataType.ICC_PROFILE, new ICCProfile(readICCProfile(chunk.getData())));
+			else if(type == ChunkType.TEXT || type == ChunkType.ITXT || type == ChunkType.ZTXT) {
+				if(textualChunk == null)
+					textualChunk = new TextualChunks();
+				textualChunk.addChunk(chunk);			
+			} else if(type == ChunkType.TIME) {
+				metadataMap.put(MetadataType.PNG_TIME, new TIMEChunk(chunk));
+			}
+			
+			LOGGER.info("{} ({}) | {} bytes | 0x{} (CRC)", type.getName(), type.getAttribute(), length, Long.toHexString(chunk.getCRC()));
+		}
+		
+		if(textualChunk != null) {
+			metadataMap.put(MetadataType.PNG_TEXTUAL, textualChunk);
+			
+			// We may find XMP data inside iTXT
+			Map<String, String> keyValMap = textualChunk.getKeyValMap();
+			
+			for (Map.Entry<String, String> entry : keyValMap.entrySet()) {
+				if(entry.getKey().equals("XML:com.adobe.xmp"))
+					metadataMap.put(MetadataType.XMP, new PngXMP(entry.getValue()));
+			}
+		}
+			
+		is.close();
+		
+		return metadataMap;
+	}
+  	
+	public static List<Chunk> removeChunks(List<Chunk> chunks, ChunkType chunkType) {
+  		
+  		Iterator<Chunk> iter = chunks.listIterator();
+   	
+   		while(iter.hasNext()) {
+   			
+   			Chunk chunk = iter.next();
+   		
+   			if (chunk.getChunkType() == chunkType) {   				
+   				iter.remove();
+   			}   			
+   		}
+   		
+   		return chunks;  		
+  	}
+   	
+   	/**
+   	 * Removes chunks which have the same ChunkType values from the chunkEnumSet.
+   	 * 
+   	 * @param chunks a list of chunks to be checked.
+   	 * @param chunkEnumSet a set of ChunkType (better use a HashSet instead of EnumSet for performance).
+   	 * @return a list of chunks with the specified chunks removed if any.
+   	 */
+   	
+   	public static List<Chunk> removeChunks(List<Chunk> chunks, Set<ChunkType> chunkEnumSet) {
+  		
+  		Iterator<Chunk> iter = chunks.listIterator();
+   	
+   		while(iter.hasNext()) {
+   			
+   			Chunk chunk = iter.next();
+   		
+   			if (chunkEnumSet.contains(chunk.getChunkType())) {   				
+   				iter.remove();
+   			}   			
+   		}
+   		
+   		return chunks;  		
+  	}
+  	
+   	public static void serializeChunks(List<Chunk> chunks, OutputStream os) throws IOException {
+  		
+  		Collections.sort(chunks);
+  	    
+  		for(Chunk chunk : chunks) {
+        	chunk.write(os);
+        }
+  	}
+  	
+  	private PNGMeta() {}
+}
diff --git a/src/pixy/meta/png/PngXMP.java b/src/pixy/meta/png/PngXMP.java
new file mode 100644
index 0000000..2ac5d2d
--- /dev/null
+++ b/src/pixy/meta/png/PngXMP.java
@@ -0,0 +1,19 @@
+package pixy.meta.png;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import pixy.meta.xmp.XMP;
+
+public class PngXMP extends XMP {
+
+	public PngXMP(String string) {
+		super(string);
+		// TODO Auto-generated constructor stub
+	}
+
+	@Override
+	public void write(OutputStream os) throws IOException {
+		// TODO Auto-generated method stub
+	}
+}
diff --git a/src/pixy/meta/png/TIMEChunk.java b/src/pixy/meta/png/TIMEChunk.java
new file mode 100644
index 0000000..9ad7ca7
--- /dev/null
+++ b/src/pixy/meta/png/TIMEChunk.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.meta.png;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import pixy.meta.Metadata;
+import pixy.meta.MetadataEntry;
+import pixy.meta.MetadataType;
+import pixy.image.png.Chunk;
+import pixy.image.png.ChunkType;
+import pixy.image.png.TIMEBuilder;
+import pixy.image.png.TIMEReader;
+
+public class TIMEChunk extends Metadata {
+
+	private static MetadataType validate(ChunkType chunkType) {
+		if(chunkType == null) throw new IllegalArgumentException("ChunkType is null");
+		if(chunkType == ChunkType.TIME)
+			return MetadataType.PNG_TIME;
+		throw new IllegalArgumentException(
+				"Input ChunkType is not tIME chunk!");
+	}
+	
+	private static void checkDate(int year, int month, int day, int hour, int minute, int second) {
+		if(year > Short.MAX_VALUE || year < Short.MIN_VALUE)
+			throw new IllegalArgumentException("Year out of range: " + Short.MIN_VALUE + " - " +  Short.MAX_VALUE);
+		if(month > 12 || month < 1)
+			throw new IllegalArgumentException("Month out of range: " + 1 + "-" + 12);
+		if(day > 31 || day < 1)
+			throw new IllegalArgumentException("Day out of range: " + 1 + "-" + 31);
+		if(hour > 23 || hour < 0)
+			throw new IllegalArgumentException("Hour out of range: " + 0 + "-" + 23);
+		if(minute > 59 || minute < 0)
+			throw new IllegalArgumentException("Minute out of range: " + 0 + "-" + 59);
+		if(second > 60 || second < 0)
+			throw new IllegalArgumentException("Second out of range: " + 0 + "-" + 60);
+	}
+	
+	private static final String[] MONTH = 
+		{"", "January", "Febrary", "March", "April",
+         "May", "June", "July", "August", "September", "October",
+         "November", "December"
+    };
+	
+	private Chunk chunk;
+	private int year;
+	private int month;
+	private int day;
+	private int hour;
+	private int minute;
+	private int second;
+	
+	public TIMEChunk(Chunk chunk) {
+		super(validate(chunk.getChunkType()), chunk.getData());
+		this.chunk = chunk;
+		ensureDataRead();
+	}
+
+	public TIMEChunk(ChunkType chunkType, int year, int month, int day, int hour, int minute, int second) {
+		super(validate(chunkType));
+		checkDate(year, month, day, hour, minute, second);
+		this.year = year;
+		this.month = month;
+		this.day = day;
+		this.hour = hour;
+		this.minute = minute;
+		this.second = second;
+		isDataRead = true;
+	}
+	
+	public Chunk getChunk() {
+		if(chunk == null)
+			chunk = new TIMEBuilder().year(year).month(month).day(day).hour(hour).minute(minute).second(second).build();
+	
+		return chunk;
+	}
+
+	public byte[] getData() {
+		return getChunk().getData();
+	}
+	
+	public int getDay() {
+		return day;
+	}
+	
+	public int getHour() {
+		return hour;
+	}
+	
+	public int getMinute() {
+		return minute;
+	}
+	
+	public int getMonth() {
+		return month;
+	}	
+	
+	public int getSecond() {
+		return second;
+	}
+	
+	public int getYear() {
+		return year;		
+	}
+	
+	public Iterator<MetadataEntry> iterator() {
+		ensureDataRead();
+		
+		List<MetadataEntry> entries = new ArrayList<MetadataEntry>();
+				
+		entries.add(new MetadataEntry("UTC (Time of last modification)", day + " " + ((month > 0 && month <= 12)? MONTH[month]:"()") + " " + year + ", " + hour + ":" + minute + ":" + second));
+		
+		return Collections.unmodifiableCollection(entries).iterator();
+	}
+	
+	public void read() throws IOException {
+		if(!isDataRead) {
+			TIMEReader reader = new TIMEReader(chunk);
+			this.year = reader.getYear();
+			this.month = reader.getMonth();;
+			this.day = reader.getDay();
+			this.hour = reader.getHour();
+			this.minute = reader.getMinute();
+			this.second = reader.getSecond();
+			isDataRead = true;
+		}
+	}
+
+	public void write(OutputStream os) throws IOException {
+		getChunk().write(os);
+	}
+}
diff --git a/src/pixy/meta/png/TextualChunks.java b/src/pixy/meta/png/TextualChunks.java
new file mode 100644
index 0000000..9ccbed6
--- /dev/null
+++ b/src/pixy/meta/png/TextualChunks.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * TextualChunk.java
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    04Nov2015  Added chunk type check
+ * WY    09Jul2015  Rewrote to work with multiple textual chunks
+ * WY    05Jul2015  Added write support
+ * WY    05Jul2015  Initial creation
+ */
+
+package pixy.meta.png;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+
+import pixy.meta.Metadata;
+import pixy.meta.MetadataEntry;
+import pixy.meta.MetadataType;
+import pixy.image.png.Chunk;
+import pixy.image.png.ChunkType;
+import pixy.image.png.TextReader;
+
+public class TextualChunks extends Metadata {
+	/* This queue is used to keep track of the unread chunks
+	 * After it's being read, all of it's elements will be moved
+	 * to chunks list
+	 */
+	private Queue<Chunk> queue;
+	// We keep chunks and keyValMap in sync
+	private List<Chunk> chunks;
+	private Map<String, String> keyValMap;
+	
+	public TextualChunks() {
+		super(MetadataType.PNG_TEXTUAL);
+		this.queue = new LinkedList<Chunk>();
+		this.chunks = new ArrayList<Chunk>();
+		this.keyValMap = new HashMap<String, String>();		
+	}
+		
+	public TextualChunks(Collection<Chunk> chunks) {
+		super(MetadataType.PNG_TEXTUAL);
+		validateChunks(chunks);
+		this.queue = new LinkedList<Chunk>(chunks);
+		this.chunks = new ArrayList<Chunk>();
+		this.keyValMap = new HashMap<String, String>();
+	}
+	
+	public List<Chunk> getChunks() {
+		ArrayList<Chunk> chunkList = new ArrayList<Chunk>(chunks);
+		chunkList.addAll(queue);		
+		return chunkList;
+	}
+	
+	public Map<String, String> getKeyValMap() {
+		ensureDataRead();
+		return Collections.unmodifiableMap(keyValMap);
+	}
+	
+	public void addChunk(Chunk chunk) {
+		validateChunkType(chunk.getChunkType());
+		queue.offer(chunk);
+	}
+	
+	public Iterator<MetadataEntry> iterator() {
+		ensureDataRead();
+		List<MetadataEntry> entries = new ArrayList<MetadataEntry>();
+			
+		for (Map.Entry<String, String> entry : keyValMap.entrySet()) {
+		    entries.add(new MetadataEntry(entry.getKey(), entry.getValue()));
+		}
+		
+		return Collections.unmodifiableCollection(entries).iterator();
+	}
+	
+	public void read() throws IOException {
+		if(queue.size() > 0) {
+			TextReader reader = new TextReader();
+			for(Chunk chunk : queue) {
+				reader.setInput(chunk);
+				String key = reader.getKeyword();
+				String text = reader.getText();
+				String oldText = keyValMap.get(key);
+				keyValMap.put(key, (oldText == null)? text: oldText + "; " + text);
+				chunks.add(chunk);
+			}
+			queue.clear();
+		}
+	}
+	
+	private static void validateChunks(Collection<Chunk> chunks) {
+		for(Chunk chunk : chunks)
+			validateChunkType(chunk.getChunkType());
+	}
+	
+	private static void validateChunkType(ChunkType chunkType) {
+		if((chunkType != ChunkType.TEXT) && (chunkType != ChunkType.ITXT) 
+				&& (chunkType != ChunkType.ZTXT))
+			throw new IllegalArgumentException("Expect Textual chunk!");
+	}
+}
diff --git a/src/pixy/meta/tiff/TIFFMeta.java b/src/pixy/meta/tiff/TIFFMeta.java
new file mode 100644
index 0000000..ed3d399
--- /dev/null
+++ b/src/pixy/meta/tiff/TIFFMeta.java
@@ -0,0 +1,1725 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * TIFFMeta.java
+ *
+ * Who   Date       Description
+ * ====  =========  =====================================================================
+ * WY    21Jun2019  Added code for removeMetadata to return the removed metadata as a map
+ * WY    14May2019  Write IPTC to normal TIFF IPTC tag instead of PhotoShop IRB block
+ * WY    06Jul2015  Added insertXMP(InputSream, OutputStream, XMP)
+ * WY    15Apr2015  Changed the argument type for insertIPTC() and insertIRB()
+ * WY    07Apr2015  Removed insertICCProfile() AWT related code
+ * WY    07Apr2015  Merge Adobe IRB IPTC and TIFF IPTC data if both exist
+ * WY    13Mar2015  Initial creation
+ */
+
+package pixy.meta.tiff;
+
+import java.io.ByteArrayOutputStream;
+import java.io.EOFException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.w3c.dom.Document;
+
+import pixy.meta.Metadata;
+import pixy.meta.MetadataType;
+import pixy.meta.adobe.DDB;
+import pixy.meta.adobe.IRB;
+import pixy.meta.adobe.IRBThumbnail;
+import pixy.meta.adobe.ThumbnailResource;
+import pixy.meta.adobe.ImageResourceID;
+import pixy.meta.adobe._8BIM;
+import pixy.meta.exif.Exif;
+import pixy.meta.exif.ExifTag;
+import pixy.meta.exif.GPSTag;
+import pixy.meta.exif.InteropTag;
+import pixy.meta.icc.ICCProfile;
+import pixy.meta.image.Comments;
+import pixy.meta.iptc.IPTC;
+import pixy.meta.iptc.IPTCDataSet;
+import pixy.meta.iptc.IPTCTag;
+import pixy.meta.xmp.XMP;
+import pixy.image.jpeg.Marker;
+import pixy.image.tiff.ASCIIField;
+import pixy.image.tiff.ByteField;
+import pixy.image.tiff.DoubleField;
+import pixy.image.tiff.FieldType;
+import pixy.image.tiff.FloatField;
+import pixy.image.tiff.IFD;
+import pixy.image.tiff.IFDField;
+import pixy.image.tiff.LongField;
+import pixy.image.tiff.MakerNoteField;
+import pixy.image.tiff.RationalField;
+import pixy.image.tiff.SByteField;
+import pixy.image.tiff.SLongField;
+import pixy.image.tiff.SRationalField;
+import pixy.image.tiff.SShortField;
+import pixy.image.tiff.ShortField;
+import pixy.image.tiff.Tag;
+import pixy.image.tiff.TiffField;
+import pixy.image.tiff.TiffFieldEnum;
+import pixy.image.tiff.TiffTag;
+import pixy.image.tiff.UndefinedField;
+import pixy.image.tiff.TIFFImage;
+import pixy.io.IOUtils;
+import pixy.io.RandomAccessInputStream;
+import pixy.io.RandomAccessOutputStream;
+import pixy.io.ReadStrategy;
+import pixy.io.ReadStrategyII;
+import pixy.io.ReadStrategyMM;
+import pixy.io.WriteStrategyII;
+import pixy.io.WriteStrategyMM;
+import pixy.string.StringUtils;
+import pixy.string.XMLUtils;
+import pixy.util.ArrayUtils;
+import android.graphics.*;
+
+public class TIFFMeta {
+	// Offset where to write the value of the first IFD offset
+	public static final int OFFSET_TO_WRITE_FIRST_IFD_OFFSET = 0x04;
+	public static final int FIRST_WRITE_OFFSET = 0x08;
+	public static final int STREAM_HEAD = 0x00;
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(TIFFMeta.class);
+	
+	private static int copyHeader(RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {		
+		rin.seek(STREAM_HEAD);
+		// First 2 bytes determine the byte order of the file, "MM" or "II"
+	    short endian = rin.readShort();
+	
+		if (endian == IOUtils.BIG_ENDIAN) {
+		    rin.setReadStrategy(ReadStrategyMM.getInstance());
+		    rout.setWriteStrategy(WriteStrategyMM.getInstance());
+		} else if(endian == IOUtils.LITTLE_ENDIAN) {
+		    rin.setReadStrategy(ReadStrategyII.getInstance());
+		    rout.setWriteStrategy(WriteStrategyII.getInstance());
+		} else {
+			rin.close();
+			rout.close();
+			throw new RuntimeException("Invalid TIFF byte order");
+	    } 
+		
+		rout.writeShort(endian);
+		// Read TIFF identifier
+		rin.seek(0x02);
+		short tiff_id = rin.readShort();
+		
+		if(tiff_id!=0x2a)//"*" 42 decimal
+		{
+		   rin.close();
+		   rout.close();
+		   throw new RuntimeException("Invalid TIFF identifier");
+		}
+		
+		rout.writeShort(tiff_id);
+		rin.seek(OFFSET_TO_WRITE_FIRST_IFD_OFFSET);
+		
+		return rin.readInt();
+	}
+	
+	private static Collection<IPTCDataSet> copyIPTCDataSet(Collection<IPTCDataSet> iptcs, byte[] data) throws IOException {
+		IPTC iptc = new IPTC(data);
+		// Shallow copy the map
+		Map<IPTCTag, List<IPTCDataSet>> dataSetMap = new HashMap<IPTCTag, List<IPTCDataSet>>(iptc.getDataSets());
+		for(IPTCDataSet set : iptcs)
+			if(!set.allowMultiple())
+				dataSetMap.remove(set.getTagEnum());
+		for(List<IPTCDataSet> iptcList : dataSetMap.values())
+			iptcs.addAll(iptcList);
+		
+		return iptcs;
+	}
+	
+	private static TiffField<?> copyJpegHufTable(RandomAccessInputStream rin, RandomAccessOutputStream rout, TiffField<?> field, int curPos) throws IOException
+	{
+		int[] data = field.getDataAsLong();
+		int[] tmp = new int[data.length];
+	
+		for(int i = 0; i < data.length; i++) {
+			rin.seek(data[i]);
+			tmp[i] = curPos;
+			byte[] htable = new byte[16];
+			IOUtils.readFully(rin, htable);
+			IOUtils.write(rout, htable);			
+			curPos += 16;
+			
+			int numCodes = 0;
+			
+            for(int j = 0; j < 16; j++) {
+                numCodes += htable[j]&0xff;
+            }
+            
+            curPos += numCodes;
+            
+            htable = new byte[numCodes];
+            IOUtils.readFully(rin, htable);
+			IOUtils.write(rout, htable);
+		}
+		
+		if(TiffTag.fromShort(field.getTag()) == TiffTag.JPEG_AC_TABLES)
+			return new LongField(TiffTag.JPEG_AC_TABLES.getValue(), tmp);
+	
+		return new LongField(TiffTag.JPEG_DC_TABLES.getValue(), tmp);
+	}
+	
+	private static void copyJpegIFByteCount(RandomAccessInputStream rin, RandomAccessOutputStream rout, int offset, int outOffset) throws IOException {		
+		boolean finished = false;
+		int length = 0;	
+		short marker;
+		Marker emarker;
+		
+		rin.seek(offset);
+		rout.seek(outOffset);
+		// The very first marker should be the start_of_image marker!	
+		if(Marker.fromShort(IOUtils.readShortMM(rin)) != Marker.SOI) {
+			return;
+		}
+		
+		IOUtils.writeShortMM(rout, Marker.SOI.getValue());
+		
+		marker = IOUtils.readShortMM(rin);
+			
+		while (!finished) {	        
+			if (Marker.fromShort(marker) == Marker.EOI) {
+				IOUtils.writeShortMM(rout, marker);
+				finished = true;
+			} else { // Read markers
+		  		emarker = Marker.fromShort(marker);
+				
+				switch (emarker) {
+					case JPG: // JPG and JPGn shouldn't appear in the image.
+					case JPG0:
+					case JPG13:
+				    case TEM: // The only stand alone mark besides SOI, EOI, and RSTn. 
+				    	marker = IOUtils.readShortMM(rin);
+				    	break;
+				    case SOS:						
+						marker = copyJpegSOS(rin, rout);
+						break;
+				    case PADDING:	
+				    	int nextByte = 0;
+				    	while((nextByte = rin.read()) == 0xff) {;}
+				    	marker = (short)((0xff<<8)|nextByte);
+				    	break;
+				    default:
+					    length = IOUtils.readUnsignedShortMM(rin);
+					    byte[] buf = new byte[length - 2];
+					    rin.read(buf);
+					    IOUtils.writeShortMM(rout, marker);
+					    IOUtils.writeShortMM(rout, length);
+					    rout.write(buf);
+					    marker = IOUtils.readShortMM(rin);					 
+				}
+			}
+	    }
+	}
+	
+	private static TiffField<?> copyJpegQTable(RandomAccessInputStream rin, RandomAccessOutputStream rout, TiffField<?> field, int curPos) throws IOException
+	{
+		byte[] qtable = new byte[64];
+		int[] data = field.getDataAsLong();
+		int[] tmp = new int[data.length];
+		
+		for(int i = 0; i < data.length; i++) {
+			rin.seek(data[i]);
+			tmp[i] = curPos;
+			IOUtils.readFully(rin, qtable);
+			IOUtils.write(rout, qtable);
+			curPos += 64;
+		}
+		
+		return new LongField(TiffTag.JPEG_Q_TABLES.getValue(), tmp);
+	}
+	
+	private static short copyJpegSOS(RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException	{
+		int len = IOUtils.readUnsignedShortMM(rin);
+		byte buf[] = new byte[len - 2];
+		IOUtils.readFully(rin, buf);
+		IOUtils.writeShortMM(rout, Marker.SOS.getValue());
+		IOUtils.writeShortMM(rout, len);
+		rout.write(buf);		
+		// Actual image data follow.
+		int nextByte = 0;
+		short marker = 0;	
+		
+		while((nextByte = IOUtils.read(rin)) != -1)	{
+			rout.write(nextByte);
+			
+			if(nextByte == 0xff)
+			{
+				nextByte = IOUtils.read(rin);
+			    rout.write(nextByte);
+			    
+				if (nextByte == -1) {
+					throw new IOException("Premature end of SOS segment!");					
+				}								
+				
+				if (nextByte != 0x00) {
+					marker = (short)((0xff<<8)|nextByte);
+					
+					switch (Marker.fromShort(marker)) {										
+						case RST0:  
+						case RST1:
+						case RST2:
+						case RST3:
+						case RST4:
+						case RST5:
+						case RST6:
+						case RST7:
+							continue;
+						default:
+					}
+					break;
+				}
+			}
+		}
+		
+		if (nextByte == -1) {
+			throw new IOException("Premature end of SOS segment!");
+		}
+
+		return marker;
+	}
+	
+	/**
+	 * @param offset offset to write page image data
+	 * 
+	 * @return the position where to write the IFD for the current image page
+	 */
+	private static int copyPageData(IFD ifd, int offset, RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {
+		int writeOffset = offset; // We copy the offset to a local variable to keep the original value
+		int writeByteCount = 0; // To fix JPEG data double copy issue
+		
+		// Move stream pointer to the right place
+		rout.seek(writeOffset);
+
+		// Original image data start from these offsets.
+		TiffField<?> stripOffSets = ifd.removeField(TiffTag.STRIP_OFFSETS);
+		
+		if(stripOffSets == null)
+			stripOffSets = ifd.removeField(TiffTag.TILE_OFFSETS);
+				
+		TiffField<?> stripByteCounts = ifd.getField(TiffTag.STRIP_BYTE_COUNTS);
+		
+		if(stripByteCounts == null)
+			stripByteCounts = ifd.getField(TiffTag.TILE_BYTE_COUNTS);	
+		/* 
+		 * Make sure this will work in the case when neither STRIP_OFFSETS nor TILE_OFFSETS presents.
+		 * Not sure if this will ever happen for TIFF. JPEG EXIF data do not contain these fields. 
+		 */
+		if(stripOffSets != null) { 
+			int[] counts = stripByteCounts.getDataAsLong();		
+			int[] off = stripOffSets.getDataAsLong();
+			int[] temp = new int[off.length];
+			
+			TiffField<?> tiffField = ifd.getField(TiffTag.COMPRESSION);
+			
+			// Uncompressed image with one strip or tile (may contain wrong StripByteCounts value)
+			// Bug fix for uncompressed image with one strip and wrong StripByteCounts value
+			if((tiffField == null ) || (tiffField != null && tiffField.getDataAsLong()[0] == 1)) { // Uncompressed data
+				int planaryConfiguration = 1;
+				
+				tiffField = ifd.getField(TiffTag.PLANAR_CONFIGURATTION);		
+				if(tiffField != null) planaryConfiguration = tiffField.getDataAsLong()[0];
+				
+				tiffField = ifd.getField(TiffTag.SAMPLES_PER_PIXEL);
+				
+				int samplesPerPixel = 1;
+				if(tiffField != null) samplesPerPixel = tiffField.getDataAsLong()[0];
+				
+				// If there is only one strip/samplesPerPixel strips for PlanaryConfiguration = 2
+				if((planaryConfiguration == 1 && off.length == 1) || (planaryConfiguration == 2 && off.length == samplesPerPixel)) {
+					int[] totalBytes2Read = getBytes2Read(ifd);
+				
+					for(int i = 0; i < off.length; i++)
+						counts[i] = totalBytes2Read[i];					
+				}				
+			} // End of bug fix
+			
+			writeByteCount = counts[0];
+			
+			// We are going to write the image data first
+			rout.seek(writeOffset);
+			
+			// Copy image data from offset
+			for(int i = 0; i < off.length; i++) {
+				rin.seek(off[i]);
+				byte[] buf = new byte[counts[i]];
+				rin.readFully(buf);
+				rout.write(buf);
+				temp[i] = writeOffset;
+				writeOffset += buf.length;
+			}
+						
+			if(ifd.getField(TiffTag.STRIP_BYTE_COUNTS) != null)
+				ifd.addField(new LongField(TiffTag.STRIP_OFFSETS.getValue(), temp));
+			else
+				ifd.addField(new LongField(TiffTag.TILE_OFFSETS.getValue(), temp));		
+		}
+		
+		// Add software field.
+		String softWare = "ICAFE - https://github.com/dragon66/icafe\0";
+		ifd.addField(new ASCIIField(TiffTag.SOFTWARE.getValue(), softWare));
+		
+		/* The following are added to work with old-style JPEG compression (type 6) */		
+		/* One of the flavors (found in JPEG EXIF thumbnail IFD - IFD1) of the old JPEG compression contains this field */
+		TiffField<?> jpegIFOffset = ifd.removeField(TiffTag.JPEG_INTERCHANGE_FORMAT);
+		if(jpegIFOffset != null) {
+			TiffField<?> jpegIFByteCount = ifd.removeField(TiffTag.JPEG_INTERCHANGE_FORMAT_LENGTH);
+			if(jpegIFOffset.getDataAsLong()[0] != stripOffSets.getDataAsLong()[0]) {
+				try {
+					if(jpegIFByteCount != null) {
+						rin.seek(jpegIFOffset.getDataAsLong()[0]);
+						byte[] bytes2Read = new byte[jpegIFByteCount.getDataAsLong()[0]];
+						rin.readFully(bytes2Read);
+						rout.seek(writeOffset);
+						rout.write(bytes2Read);
+						ifd.addField(jpegIFByteCount);
+					} else {
+						long startOffset = rout.getStreamPointer();					
+						copyJpegIFByteCount(rin, rout, jpegIFOffset.getDataAsLong()[0], writeOffset);
+						long endOffset = rout.getStreamPointer();
+						ifd.addField(new LongField(TiffTag.JPEG_INTERCHANGE_FORMAT_LENGTH.getValue(), new int[]{(int)(endOffset - startOffset)}));
+					}
+					jpegIFOffset = new LongField(TiffTag.JPEG_INTERCHANGE_FORMAT.getValue(), new int[]{writeOffset});
+					ifd.addField(jpegIFOffset);
+				} catch (EOFException ex) {;};
+			} else { // To fix the issue of double copy the JPEG data, we can safely re-assign the pointers.
+				ifd.addField(new LongField(TiffTag.JPEG_INTERCHANGE_FORMAT.getValue(), new int[]{offset}));
+				ifd.addField(new LongField(TiffTag.JPEG_INTERCHANGE_FORMAT_LENGTH.getValue(), new int[]{writeByteCount}));
+			}
+		}		
+		/* Another flavor of the old style JPEG compression type 6 contains separate tables */
+		TiffField<?> jpegTable = ifd.removeField(TiffTag.JPEG_DC_TABLES);
+		if(jpegTable != null) {
+			try {
+				ifd.addField(copyJpegHufTable(rin, rout, jpegTable, (int)rout.getStreamPointer()));
+			} catch(EOFException ex) {;}
+		}
+		
+		jpegTable = ifd.removeField(TiffTag.JPEG_AC_TABLES);
+		if(jpegTable != null) {
+			try {
+				ifd.addField(copyJpegHufTable(rin, rout, jpegTable, (int)rout.getStreamPointer()));
+			} catch(EOFException ex) {;}
+		}
+	
+		jpegTable = ifd.removeField(TiffTag.JPEG_Q_TABLES);
+		if(jpegTable != null) {
+			try {
+				ifd.addField(copyJpegQTable(rin, rout, jpegTable, (int)rout.getStreamPointer()));
+			} catch(EOFException ex) {;}
+		}
+		/* End of code to work with old-style JPEG compression */
+		
+		// Return the actual stream position (we may have lost track of it)  
+		return (int)rout.getStreamPointer();	
+	}
+	
+	// Copy a list of IFD and associated image data if any
+	private static int copyPages(List<IFD> list, int writeOffset, RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {
+		// Write the first page data
+		writeOffset = copyPageData(list.get(0), writeOffset, rin, rout);
+		// Then write the first IFD
+		writeOffset = list.get(0).write(rout, writeOffset);
+		// We are going to write the remaining image pages and IFDs if any
+		for(int i = 1; i < list.size(); i++) {
+			writeOffset = copyPageData(list.get(i), writeOffset, rin, rout);
+			// Tell the IFD to update next IFD offset for the following IFD
+			list.get(i-1).setNextIFDOffset(rout, writeOffset); 
+			writeOffset = list.get(i).write(rout, writeOffset);
+		}
+		
+		return writeOffset;
+	}
+	
+	/**
+	 * Extracts ICC_Profile from certain page of TIFF if any
+	 * 
+	 * @param pageNumber page number from which to extract ICC_Profile
+	 * @param rin RandomAccessInputStream for the input TIFF
+	 * @return a byte array for the extracted ICC_Profile or null if none exists
+	 * @throws Exception
+	 */
+	public static byte[] extractICCProfile(int pageNumber, RandomAccessInputStream rin) throws Exception {
+		// Read pass image header
+		int offset = readHeader(rin);
+		// Read the IFDs into a list first
+		List<IFD> ifds = new ArrayList<IFD>();
+		readIFDs(null, null, TiffTag.class, ifds, offset, rin);
+		
+		if(pageNumber < 0 || pageNumber >= ifds.size())
+			throw new IllegalArgumentException("pageNumber " + pageNumber + " out of bounds: 0 - " + (ifds.size() - 1));
+		
+		IFD workingPage = ifds.get(pageNumber);
+		TiffField<?> f_iccProfile = workingPage.getField(TiffTag.ICC_PROFILE);
+		if(f_iccProfile != null) {
+			return (byte[])f_iccProfile.getData();
+		}
+		
+		return null;
+	}
+	
+	public static byte[] extractICCProfile(RandomAccessInputStream rin) throws Exception {
+		return extractICCProfile(0, rin);
+	}
+	
+	public static IRBThumbnail extractThumbnail(int pageNumber, RandomAccessInputStream rin) throws IOException {
+		// Read pass image header
+		int offset = readHeader(rin);
+		// Read the IFDs into a list first
+		List<IFD> ifds = new ArrayList<IFD>();
+		readIFDs(null, null, TiffTag.class, ifds, offset, rin);
+		
+		if(pageNumber < 0 || pageNumber >= ifds.size())
+			throw new IllegalArgumentException("pageNumber " + pageNumber + " out of bounds: 0 - " + (ifds.size() - 1));
+		
+		IFD workingPage = ifds.get(pageNumber);
+		TiffField<?> f_photoshop = workingPage.getField(TiffTag.PHOTOSHOP);
+		if(f_photoshop != null) {
+			byte[] data = (byte[])f_photoshop.getData();
+			IRB irb = new IRB(data);
+			if(irb.containsThumbnail()) {
+				IRBThumbnail thumbnail = irb.getThumbnail();
+				return thumbnail;					
+			}		
+		}
+		
+		return null;
+	}
+	
+	public static IRBThumbnail extractThumbnail(RandomAccessInputStream rin) throws IOException {
+		return extractThumbnail(0, rin);
+	}
+	
+	public static void extractThumbnail(RandomAccessInputStream rin, String pathToThumbnail) throws IOException {
+		IRBThumbnail thumbnail = extractThumbnail(rin);				
+		if(thumbnail != null) {
+			String outpath = "";
+			if(pathToThumbnail.endsWith("\\") || pathToThumbnail.endsWith("/"))
+				outpath = pathToThumbnail + "photoshop_thumbnail.jpg";
+			else
+				outpath = pathToThumbnail.replaceFirst("[.][^.]+$", "") + "_photoshop_t.jpg";
+			FileOutputStream fout = new FileOutputStream(outpath);
+			if(thumbnail.getDataType() == IRBThumbnail.DATA_TYPE_KJpegRGB) {
+				fout.write(thumbnail.getCompressedImage());
+			} else {
+				Bitmap bm = thumbnail.getRawImage();
+				try {
+					bm.compress(Bitmap.CompressFormat.JPEG, 100, fout);
+				} catch (Exception e) {
+					throw new IOException("Writing thumbnail failed!");
+				}
+			}
+			fout.close();	
+		}			
+	}
+	
+	// Used to calculate how many bytes to read in case we have only one strip or tile
+	private static int[] getBytes2Read(IFD ifd) {
+		// Let's calculate how many bytes we are supposed to read
+		TiffField<?> tiffField = ifd.getField(TiffTag.IMAGE_WIDTH);
+		int imageWidth = tiffField.getDataAsLong()[0];
+		tiffField = ifd.getField(TiffTag.IMAGE_LENGTH);
+		int imageHeight = tiffField.getDataAsLong()[0];
+		
+		// For YCbCr image only
+		int horizontalSampleFactor = 2; // Default 2X2
+		int verticalSampleFactor = 2; // Not 1X1
+		
+		int photoMetric = ifd.getField(TiffTag.PHOTOMETRIC_INTERPRETATION).getDataAsLong()[0];
+		
+		// Correction for imageWidth and imageHeight for YCbCr image
+		if(photoMetric == TiffFieldEnum.PhotoMetric.YCbCr.getValue()) {
+			TiffField<?> f_YCbCrSubSampling = ifd.getField(TiffTag.YCbCr_SUB_SAMPLING);
+			
+			if(f_YCbCrSubSampling != null) {
+				int[] sampleFactors = f_YCbCrSubSampling.getDataAsLong();
+				horizontalSampleFactor = sampleFactors[0];
+				verticalSampleFactor = sampleFactors[1];
+			}
+			imageWidth = ((imageWidth + horizontalSampleFactor - 1)/horizontalSampleFactor)*horizontalSampleFactor;
+			imageHeight = ((imageHeight + verticalSampleFactor - 1)/verticalSampleFactor)*verticalSampleFactor;	
+		}
+		
+		int samplesPerPixel = 1;
+		
+		tiffField = ifd.getField(TiffTag.SAMPLES_PER_PIXEL);
+		if(tiffField != null) {
+			samplesPerPixel = tiffField.getDataAsLong()[0];
+		}				
+		
+		int bitsPerSample = 1;
+		
+		tiffField = ifd.getField(TiffTag.BITS_PER_SAMPLE);
+		if(tiffField != null) {
+			bitsPerSample = tiffField.getDataAsLong()[0];
+		}
+		
+		int tileWidth = -1;
+		int tileLength = -1;			
+		
+		TiffField<?> f_tileLength = ifd.getField(TiffTag.TILE_LENGTH);
+		TiffField<?> f_tileWidth = ifd.getField(TiffTag.TILE_WIDTH);
+		
+		if(f_tileWidth != null) {
+			tileWidth = f_tileWidth.getDataAsLong()[0];
+			tileLength = f_tileLength.getDataAsLong()[0];
+		}
+		
+		int rowsPerStrip = imageHeight;
+		int rowWidth = imageWidth;
+		
+		TiffField<?> f_rowsPerStrip = ifd.getField(TiffTag.ROWS_PER_STRIP);
+		if(f_rowsPerStrip != null) rowsPerStrip = f_rowsPerStrip.getDataAsLong()[0];					
+		
+		if(rowsPerStrip > imageHeight) rowsPerStrip = imageHeight;
+		
+		if(tileWidth > 0) {
+			rowsPerStrip = tileLength;
+			rowWidth = tileWidth;
+		}
+	
+		int planaryConfiguration = 1;
+		
+		tiffField = ifd.getField(TiffTag.PLANAR_CONFIGURATTION);
+		if(tiffField != null) planaryConfiguration = tiffField.getDataAsLong()[0];
+		
+		int[] totalBytes2Read = new int[samplesPerPixel];		
+		
+		if(planaryConfiguration == 1)
+			totalBytes2Read[0] = ((rowWidth*bitsPerSample*samplesPerPixel + 7)/8)*rowsPerStrip;
+		else
+			totalBytes2Read[0] = totalBytes2Read[1] = totalBytes2Read[2] = ((rowWidth*bitsPerSample + 7)/8)*rowsPerStrip;
+		
+		if(photoMetric == TiffFieldEnum.PhotoMetric.YCbCr.getValue()) {
+			if(samplesPerPixel != 3) samplesPerPixel = 3;
+			
+			int[] sampleBytesPerRow = new int[samplesPerPixel];
+			sampleBytesPerRow[0] = (bitsPerSample*rowWidth + 7)/8;
+			sampleBytesPerRow[1] = (bitsPerSample*rowWidth/horizontalSampleFactor + 7)/8;
+			sampleBytesPerRow[2] = sampleBytesPerRow[1];
+			
+			int[] sampleRowsPerStrip = new int[samplesPerPixel];
+			sampleRowsPerStrip[0] = rowsPerStrip;
+			sampleRowsPerStrip[1] = rowsPerStrip/verticalSampleFactor;
+			sampleRowsPerStrip[2]= sampleRowsPerStrip[1];
+			
+			totalBytes2Read[0] = sampleBytesPerRow[0]*sampleRowsPerStrip[0];
+			totalBytes2Read[1] = sampleBytesPerRow[1]*sampleRowsPerStrip[1];
+			totalBytes2Read[2] = totalBytes2Read[1];
+		
+			if(tiffField != null) planaryConfiguration = tiffField.getDataAsLong()[0];
+		
+			if(planaryConfiguration == 1)
+				totalBytes2Read[0] = totalBytes2Read[0] + totalBytes2Read[1] + totalBytes2Read[2];			
+		}
+		
+		return totalBytes2Read;
+	}
+	
+	public static void insertComments(List<String> comments, RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {
+		insertComments(comments, 0, rin, rout);
+	}
+		
+	public static void insertComments(List<String> comments, int pageNumber, RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {
+		int offset = copyHeader(rin, rout);
+		// Read the IFDs into a list first
+		List<IFD> ifds = new ArrayList<IFD>();
+		readIFDs(null, null, TiffTag.class, ifds, offset, rin);
+		
+		if(pageNumber < 0 || pageNumber >= ifds.size())
+			throw new IllegalArgumentException("pageNumber " + pageNumber + " out of bounds: 0 - " + (ifds.size() - 1));
+		
+		IFD workingPage = ifds.get(pageNumber);
+
+		StringBuilder commentsBuilder = new StringBuilder();
+		
+		// ASCII field allows for multiple strings
+		for(String comment : comments) {
+			commentsBuilder.append(comment);
+			commentsBuilder.append('\0');
+		}
+		
+		workingPage.addField(new ASCIIField(TiffTag.IMAGE_DESCRIPTION.getValue(), commentsBuilder.toString()));
+		
+		offset = copyPages(ifds, offset, rin, rout);
+		int firstIFDOffset = ifds.get(0).getStartOffset();	
+
+		writeToStream(rout, firstIFDOffset);	
+	}
+	
+	public static void insertExif(RandomAccessInputStream rin, RandomAccessOutputStream rout, Exif exif, boolean update) throws IOException {
+		insertExif(rin, rout, exif, 0, update);
+	}
+	
+	/**
+	 * Insert EXIF data with optional thumbnail IFD
+	 * 
+	 * @param rin input image stream
+	 * @param rout output image stream
+	 * @param exif EXIF wrapper instance
+	 * @param pageNumber page offset where to insert EXIF (zero based)
+	 * @param update True to keep the original data, otherwise false
+	 * @throws Exception
+	 */
+	public static void insertExif(RandomAccessInputStream rin, RandomAccessOutputStream rout, Exif exif, int pageNumber, boolean update) throws IOException {
+		int offset = copyHeader(rin, rout);
+		// Read the IFDs into a list first
+		List<IFD> ifds = new ArrayList<IFD>();
+		readIFDs(null, null, TiffTag.class, ifds, offset, rin);
+		
+		if(pageNumber < 0 || pageNumber >= ifds.size())
+			throw new IllegalArgumentException("pageNumber " + pageNumber + " out of bounds: 0 - " + (ifds.size() - 1));
+		
+		IFD imageIFD = ifds.get(pageNumber);
+		IFD exifSubIFD = imageIFD.getChild(TiffTag.EXIF_SUB_IFD);
+		IFD gpsSubIFD = imageIFD.getChild(TiffTag.GPS_SUB_IFD);
+		IFD interopSubIFD = (exifSubIFD != null)? exifSubIFD.getChild(ExifTag.EXIF_INTEROPERABILITY_OFFSET) : null;
+		IFD newImageIFD = exif.getImageIFD();
+		IFD newExifSubIFD = exif.getExifIFD();
+		IFD newGpsSubIFD = exif.getGPSIFD();
+		IFD newInteropSubIFD = exif.getInteropIFD();
+		
+		if(newImageIFD != null) {
+			Collection<TiffField<?>> fields = newImageIFD.getFields();
+			for(TiffField<?> field : fields) {
+				Tag tag = TiffTag.fromShort(field.getTag());
+				if(imageIFD.getField(tag) != null && tag.isCritical())
+					throw new RuntimeException("Override of TIFF critical Tag - " + tag.getName() + " is not allowed!");
+				imageIFD.addField(field);
+			}
+		}
+		
+		if(update && exifSubIFD != null && newExifSubIFD != null) {
+			exifSubIFD.addFields(newExifSubIFD.getFields());
+			newExifSubIFD = exifSubIFD;
+		}
+		
+		if(newExifSubIFD != null) {
+			imageIFD.addField(new LongField(TiffTag.EXIF_SUB_IFD.getValue(), new int[]{0})); // Place holder
+			imageIFD.addChild(TiffTag.EXIF_SUB_IFD, newExifSubIFD);
+			if(update && interopSubIFD != null && newInteropSubIFD != null) {
+				interopSubIFD.addFields(newInteropSubIFD.getFields());
+				newInteropSubIFD = interopSubIFD;
+			}
+			if(newInteropSubIFD != null) {
+				newExifSubIFD.addField(new LongField(ExifTag.EXIF_INTEROPERABILITY_OFFSET.getValue(), new int[]{0})); // Place holder
+				newExifSubIFD.addChild(ExifTag.EXIF_INTEROPERABILITY_OFFSET, newInteropSubIFD);		
+			}
+		}
+		
+		if(update && gpsSubIFD != null && newGpsSubIFD != null) {
+			gpsSubIFD.addFields(newGpsSubIFD.getFields());
+			newGpsSubIFD = gpsSubIFD;
+		}
+		
+		if(newGpsSubIFD != null) {
+			imageIFD.addField(new LongField(TiffTag.GPS_SUB_IFD.getValue(), new int[]{0})); // Place holder
+			imageIFD.addChild(TiffTag.GPS_SUB_IFD, newGpsSubIFD);		
+		}
+		
+		int writeOffset = FIRST_WRITE_OFFSET;
+		// Copy pages
+		writeOffset = copyPages(ifds, writeOffset, rin, rout);
+		int firstIFDOffset = ifds.get(0).getStartOffset();
+
+		writeToStream(rout, firstIFDOffset);
+	}
+	
+	public static void insertICCProfile(byte[] icc_profile, RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {
+		insertICCProfile(icc_profile, 0, rin, rout);
+	}
+	
+	/**
+	 * Insert ICC_Profile into TIFF page
+	 * 
+	 * @param icc_profile byte array holding the ICC_Profile
+	 * @param pageNumber page offset where to insert ICC_Profile
+	 * @param rin RandomAccessInputStream for the input image
+	 * @param rout RandomAccessOutputStream for the output image
+	 * @throws Exception
+	 */
+	public static void insertICCProfile(byte[] icc_profile, int pageNumber, RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {
+		int offset = copyHeader(rin, rout);
+		// Read the IFDs into a list first
+		List<IFD> ifds = new ArrayList<IFD>();
+		readIFDs(null, null, TiffTag.class, ifds, offset, rin);
+		
+		if(pageNumber < 0 || pageNumber >= ifds.size())
+			throw new IllegalArgumentException("pageNumber " + pageNumber + " out of bounds: 0 - " + (ifds.size() - 1));
+		
+		IFD workingPage = ifds.get(pageNumber);
+		workingPage.addField(new UndefinedField(TiffTag.ICC_PROFILE.getValue(), icc_profile));
+		
+		offset = copyPages(ifds, offset, rin, rout);
+		int firstIFDOffset = ifds.get(0).getStartOffset();	
+
+		writeToStream(rout, firstIFDOffset);	
+	}
+	
+	public static void insertIPTC(RandomAccessInputStream rin, RandomAccessOutputStream rout, Collection<IPTCDataSet> iptcs, boolean update) throws IOException {
+		insertIPTC(rin, rout, 0, iptcs, update);
+	}
+	
+	/**
+	 * Insert IPTC data into TIFF image. If the original TIFF image contains IPTC data, we either keep
+	 * or override them depending on the input parameter "update."
+	 * <p>
+	 * There is a possibility that IPTC data presents in more than one places such as a normal TIFF
+	 * tag, or buried inside a Photoshop IPTC-NAA Image Resource Block (IRB), or even in a XMP block.
+	 * Currently this method does the following thing: if no IPTC data was found from both Photoshop or 
+	 * normal IPTC tag, we insert the IPTC data with a normal IPTC tag. If IPTC data is found both as
+	 * a Photoshop tag and a normal IPTC tag, depending on the "update" parameter, we will either delete
+	 * the IPTC data from both places and insert the new IPTC data into the Photoshop tag or we will
+	 * synchronize the two sets of IPTC data, delete the original IPTC from both places and insert the
+	 * synchronized IPTC data along with the new IPTC data into the Photoshop tag. In both cases, we
+	 * will keep the other IRBs from the original Photoshop tag unchanged. 
+	 * 
+	 * @param rin RandomAccessInputStream for the original TIFF
+	 * @param rout RandomAccessOutputStream for the output TIFF with IPTC inserted
+	 * @param pageNumber page offset where to insert IPTC
+	 * @param iptcs A list of IPTCDataSet to insert into the TIFF image
+	 * @param update whether we want to keep the original IPTC data or override it
+	 *        completely new IPTC data set
+	 * @throws IOException
+	 */
+	public static void insertIPTC(RandomAccessInputStream rin, RandomAccessOutputStream rout, int pageNumber, Collection<IPTCDataSet> iptcs, boolean update) throws IOException {
+		int offset = copyHeader(rin, rout);
+		// Read the IFDs into a list first
+		List<IFD> ifds = new ArrayList<IFD>();
+		readIFDs(null, null, TiffTag.class, ifds, offset, rin);
+		
+		if(pageNumber < 0 || pageNumber >= ifds.size())
+			throw new IllegalArgumentException("pageNumber " + pageNumber + " out of bounds: 0 - " + (ifds.size() - 1));
+		
+		IFD workingPage = ifds.get(pageNumber);
+	
+		ByteArrayOutputStream bout = new ByteArrayOutputStream();
+		
+		// See if we also have regular IPTC tag field
+		TiffField<?> f_iptc = workingPage.removeField(TiffTag.IPTC);		
+		TiffField<?> f_photoshop = workingPage.getField(TiffTag.PHOTOSHOP);
+		if(f_photoshop != null) { // Read 8BIMs
+			IRB irb = new IRB((byte[])f_photoshop.getData());
+			// Shallow copy the map.
+			Map<Short, _8BIM> bims = new HashMap<Short, _8BIM>(irb.get8BIM());
+			_8BIM photoshop_iptc = bims.remove(ImageResourceID.IPTC_NAA.getValue());
+			if(photoshop_iptc != null) { // If we have IPTC
+				if(update) { // If we need to keep the old data, copy it
+					if(f_iptc != null) {// We are going to synchronize the two IPTC data
+						byte[] data = null;
+						if(f_iptc.getType() == FieldType.LONG)
+							data = ArrayUtils.toByteArray(f_iptc.getDataAsLong(), rin.getEndian() == IOUtils.BIG_ENDIAN);
+						else
+							data = (byte[])f_iptc.getData();
+						copyIPTCDataSet(iptcs, data);
+					}
+					// Now copy the Photoshop IPTC data
+					copyIPTCDataSet(iptcs, photoshop_iptc.getData());
+					// Remove duplicates
+					iptcs = new ArrayList<IPTCDataSet>(new HashSet<IPTCDataSet>(iptcs));
+				}
+			}
+			for(_8BIM bim : bims.values()) // Copy the other 8BIMs if any
+				bim.write(bout);
+			// Add a new Photoshop tag field to TIFF
+			workingPage.addField(new UndefinedField(TiffTag.PHOTOSHOP.getValue(), bout.toByteArray()));
+		} else { // We don't have photoshop, copy the old IPTC data in the IPTC tag is any
+			if(f_iptc != null && update) {
+				byte[] data = null;
+				if(f_iptc.getType() == FieldType.LONG)
+					data = ArrayUtils.toByteArray(f_iptc.getDataAsLong(), rin.getEndian() == IOUtils.BIG_ENDIAN);
+				else
+					data = (byte[])f_iptc.getData();
+				copyIPTCDataSet(iptcs, data);
+			}
+		}
+		
+		// Sort the IPTCDataSet collection
+		List<IPTCDataSet> iptcList = new ArrayList<IPTCDataSet>(iptcs);
+		Collections.sort(iptcList);
+		// Write IPTCDataSet collection
+		bout.reset();
+		for(IPTCDataSet dataset : iptcList) {
+			dataset.write(bout);
+		}
+		// Add IPTC to regular IPTC tag field
+		workingPage.addField(new UndefinedField(TiffTag.IPTC.getValue(), bout.toByteArray()));
+		
+		offset = copyPages(ifds, offset, rin, rout);
+		int firstIFDOffset = ifds.get(0).getStartOffset();	
+
+		writeToStream(rout, firstIFDOffset);	
+	}
+	
+	public static void insertIRB(RandomAccessInputStream rin, RandomAccessOutputStream rout, Collection<_8BIM> bims, boolean update) throws IOException {
+		insertIRB(rin, rout, 0, bims, update);
+	}
+	
+	public static void insertIRB(RandomAccessInputStream rin, RandomAccessOutputStream rout, int pageNumber, Collection<_8BIM> bims, boolean update) throws IOException {
+		int offset = copyHeader(rin, rout);
+		// Read the IFDs into a list first
+		List<IFD> ifds = new ArrayList<IFD>();
+		readIFDs(null, null, TiffTag.class, ifds, offset, rin);
+	
+		if(pageNumber < 0 || pageNumber >= ifds.size())
+			throw new IllegalArgumentException("pageNumber " + pageNumber + " out of bounds: 0 - " + (ifds.size() - 1));
+		
+		IFD workingPage = ifds.get(pageNumber);
+		
+		ByteArrayOutputStream bout = new ByteArrayOutputStream();
+		
+		if(update) {
+			TiffField<?> f_irb = workingPage.getField(TiffTag.PHOTOSHOP);
+			if(f_irb != null) {
+				IRB irb = new IRB((byte[])f_irb.getData());
+				// Shallow copy the map.
+	    		Map<Short, _8BIM> bimMap = new HashMap<Short, _8BIM>(irb.get8BIM());
+				for(_8BIM bim : bims) // Replace the original data
+					bimMap.put(bim.getID(), bim);
+				// In case we have two ThumbnailResource IRB, remove the Photoshop4.0 one
+				if(bimMap.containsKey(ImageResourceID.THUMBNAIL_RESOURCE_PS4.getValue()) 
+						&& bimMap.containsKey(ImageResourceID.THUMBNAIL_RESOURCE_PS5.getValue()))
+					bimMap.remove(ImageResourceID.THUMBNAIL_RESOURCE_PS4.getValue());
+				bims = bimMap.values();
+			}
+		}
+		
+		for(_8BIM bim : bims)
+			bim.write(bout);
+		
+		workingPage.addField(new UndefinedField(TiffTag.PHOTOSHOP.getValue(), bout.toByteArray()));
+		
+		offset = copyPages(ifds, offset, rin, rout);
+		int firstIFDOffset = ifds.get(0).getStartOffset();	
+
+		writeToStream(rout, firstIFDOffset);	
+	}
+	
+	/**
+	 * Insert a thumbnail into PHOTOSHOP private tag field
+	 *  
+	 * @param rin RandomAccessInputStream for the input TIFF
+	 * @param rout RandomAccessOutputStream for the output TIFF
+	 * @param thumbnail a Bitmap to be inserted
+	 * @throws Exception
+	 */
+	public static void insertThumbnail(RandomAccessInputStream rin, RandomAccessOutputStream rout, Bitmap thumbnail) throws IOException {
+		// Sanity check
+		if(thumbnail == null) throw new IllegalArgumentException("Input thumbnail is null");
+		_8BIM bim = new ThumbnailResource(thumbnail);
+		insertIRB(rin, rout, Arrays.asList(bim), true);
+	}
+	
+	public static void insertXMP(XMP xmp, RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {
+		insertXMP(xmp.getData(), rin, rout);
+	}
+	
+	public static void insertXMP(XMP xmp, int pageNumber, RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {
+		insertXMP(xmp.getData(), pageNumber, rin, rout);
+	}
+	
+	public static void insertXMP(byte[] xmp, RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {
+		insertXMP(xmp, 0, rin, rout);
+	}
+	
+	/**
+	 * Insert XMP data into TIFF image
+	 * @param xmp byte array for the XMP data to be inserted
+	 * @param pageNumber page offset where to insert XMP
+	 * @param rin RandomAccessInputStream for the input image
+	 * @param rout RandomAccessOutputStream for the output image
+	 * @throws IOException
+	 */
+	public static void insertXMP(byte[] xmp, int pageNumber, RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {
+		int offset = copyHeader(rin, rout);
+		// Read the IFDs into a list first
+		List<IFD> ifds = new ArrayList<IFD>();
+		readIFDs(null, null, TiffTag.class, ifds, offset, rin);
+		
+		if(pageNumber < 0 || pageNumber >= ifds.size())
+			throw new IllegalArgumentException("pageNumber " + pageNumber + " out of bounds: 0 - " + (ifds.size() - 1));
+		
+		IFD workingPage = ifds.get(pageNumber);
+		workingPage.addField(new UndefinedField(TiffTag.XMP.getValue(), xmp));
+		
+		offset = copyPages(ifds, offset, rin, rout);
+		int firstIFDOffset = ifds.get(0).getStartOffset();	
+
+		writeToStream(rout, firstIFDOffset);	
+	}
+	
+	public static void insertXMP(String xmp, RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {
+		Document doc = XMLUtils.createXML(xmp);
+		XMLUtils.insertLeadingPI(doc, "xpacket", "begin='' id='W5M0MpCehiHzreSzNTczkc9d'");
+		XMLUtils.insertTrailingPI(doc, "xpacket", "end='w'");
+		byte[] xmpBytes = XMLUtils.serializeToByteArray(doc);
+		insertXMP(xmpBytes, rin, rout);
+	}
+	
+	public static void printIFDs(Collection<IFD> list, String indent) {
+		int id = 0;
+		LOGGER.info("Printing IFDs ... ");
+		
+		for(IFD currIFD : list) {
+			LOGGER.info("IFD #{}", id);
+			printIFD(currIFD, TiffTag.class, indent);
+			id++;
+		}
+	}
+	
+	public static void printIFD(IFD currIFD, Class<? extends Tag> tagClass, String indent) {
+		StringBuilder ifd = new StringBuilder();
+		print(currIFD, tagClass, indent, ifd);
+		LOGGER.info("\n{}", ifd);
+	}
+	
+	private static void print(IFD currIFD, Class<? extends Tag> tagClass, String indent, StringBuilder ifds) {
+		// Use reflection to invoke fromShort(short) method
+		Method method = null;
+		try {
+			method = tagClass.getDeclaredMethod("fromShort", short.class);
+		} catch (NoSuchMethodException e) {
+			e.printStackTrace();
+		} catch (SecurityException e) {
+			e.printStackTrace();
+		}
+		Collection<TiffField<?>> fields = currIFD.getFields();
+		int i = 0;
+		
+		for(TiffField<?> field : fields) {
+			ifds.append(indent);
+			ifds.append("Field #" + i + "\n");
+			ifds.append(indent);
+			short tag = field.getTag();
+			Tag ftag = TiffTag.UNKNOWN;
+			if(tag == ExifTag.PADDING.getValue()) {
+				ftag = ExifTag.PADDING;
+			} else {
+				try {
+					ftag = (Tag)method.invoke(null, tag);
+				} catch (IllegalAccessException e) {
+					LOGGER.error("IllegalAcessException", e);
+				} catch (IllegalArgumentException e) {
+					LOGGER.error("IllegalArgumentException", e);
+				} catch (InvocationTargetException e) {
+					LOGGER.error("InvocationTargetException", e);
+				}
+			}
+			if (ftag == TiffTag.UNKNOWN) {
+				LOGGER.warn("Tag: {} {}{}{} {}", ftag, "[Value: 0x", Integer.toHexString(tag&0xffff), "]", "(Unknown)");
+			} else {
+				ifds.append("Tag: " + ftag + "\n");
+			}
+			FieldType ftype = field.getType();				
+			ifds.append(indent);
+			ifds.append("Field type: " + ftype + "\n");
+			int field_length = field.getLength();
+			ifds.append(indent);
+			ifds.append("Field length: " + field_length + "\n");
+			ifds.append(indent);			
+			
+			String suffix = null;
+			if(ftype == FieldType.SHORT || ftype == FieldType.SSHORT)
+				suffix = ftag.getFieldAsString(field.getDataAsLong());
+			else
+				suffix = ftag.getFieldAsString(field.getData());			
+			
+			ifds.append("Field value: " + field.getDataAsString() + (StringUtils.isNullOrEmpty(suffix)?"":" => " + suffix) + "\n");
+			
+			i++;
+		}
+		
+		Map<Tag, IFD> children = currIFD.getChildren();
+		
+		if(children.get(TiffTag.EXIF_SUB_IFD) != null) {
+			ifds.append(indent + "--------- ");
+			ifds.append("<<Exif SubIFD starts>>\n");
+			print(children.get(TiffTag.EXIF_SUB_IFD), ExifTag.class, indent + "--------- ", ifds);
+			ifds.append(indent + "--------- ");
+			ifds.append("<<Exif SubIFD ends>>\n");
+		}
+		
+		if(children.get(TiffTag.GPS_SUB_IFD) != null) {
+			ifds.append(indent + "--------- ");
+			ifds.append("<<GPS SubIFD starts>>\n");
+			print(children.get(TiffTag.GPS_SUB_IFD), GPSTag.class, indent + "--------- ", ifds);
+			ifds.append(indent + "--------- ");
+			ifds.append("<<GPS SubIFD ends>>\n");
+		}		
+	}
+	
+	private static int readHeader(RandomAccessInputStream rin) throws IOException {
+		int offset = 0;
+	    // First 2 bytes determine the byte order of the file
+		rin.seek(STREAM_HEAD);
+	    short endian = rin.readShort();
+	    offset += 2;
+	
+		if (endian == IOUtils.BIG_ENDIAN) {
+		    rin.setReadStrategy(ReadStrategyMM.getInstance());
+		} else if(endian == IOUtils.LITTLE_ENDIAN) {
+		    rin.setReadStrategy(ReadStrategyII.getInstance());
+		} else {		
+			rin.close();
+			throw new RuntimeException("Invalid TIFF byte order");
+	    }
+		
+		// Read TIFF identifier
+		rin.seek(offset);
+		short tiff_id = rin.readShort();
+		offset +=2;
+		
+		if(tiff_id!=0x2a) { //"*" 42 decimal
+			rin.close();
+			throw new RuntimeException("Invalid TIFF identifier");
+		}
+		
+		rin.seek(offset);
+		offset = rin.readInt();
+			
+		return offset;
+	}
+	
+	// Read IFD without header
+	public static int readIFD(RandomAccessInputStream rin, List<IFD> list, Class<? extends Tag> tagClass) throws IOException {
+		return readIFD(null, null, tagClass, rin, list, 0);
+	}
+	
+	private static int readIFD(IFD parent, Tag parentTag, Class<? extends Tag> tagClass, RandomAccessInputStream rin, List<IFD> list, int offset) throws IOException {	
+		// Use reflection to invoke fromShort(short) method
+		Method method = null;
+		try {
+			method = tagClass.getDeclaredMethod("fromShort", short.class);
+		} catch (NoSuchMethodException e) {
+			e.printStackTrace();
+		} catch (SecurityException e) {
+			e.printStackTrace();
+		}
+		IFD tiffIFD = new IFD();
+		rin.seek(offset);
+		int no_of_fields = rin.readShort();
+		offset += 2;
+		
+		for (int i = 0; i < no_of_fields; i++) {
+			rin.seek(offset);
+			short tag = rin.readShort();
+			Tag ftag = TiffTag.UNKNOWN;
+			try {
+				ftag = (Tag)method.invoke(null, tag);
+			} catch (IllegalAccessException e) {
+				e.printStackTrace();
+			} catch (IllegalArgumentException e) {
+				e.printStackTrace();
+			} catch (InvocationTargetException e) {
+				e.printStackTrace();
+			}
+			offset += 2;
+			rin.seek(offset);
+			short type = rin.readShort();
+			FieldType ftype = FieldType.fromShort(type);
+			offset += 2;
+			rin.seek(offset);
+			int field_length = rin.readInt();
+			offset += 4;
+			////// Try to read actual data.
+			switch (ftype) {
+				case BYTE:
+				case SBYTE:
+				case UNDEFINED:
+					byte[] data = new byte[field_length];
+					rin.seek(offset);
+					if(field_length <= 4) {						
+						rin.readFully(data, 0, field_length);					   
+					} else {
+						rin.seek(rin.readInt());
+						rin.readFully(data, 0, field_length);
+					}					
+					TiffField<byte[]> byteField = null;
+					if(ftype == FieldType.BYTE) {
+						byteField = new ByteField(tag, data);
+					} else if(ftype == FieldType.SBYTE) {
+						byteField = new SByteField(tag, data);
+					} else {
+						if(ftag == ExifTag.MAKER_NOTE)
+							byteField = new MakerNoteField(tiffIFD, data);
+						else
+							byteField = new UndefinedField(tag, data);
+					}
+					tiffIFD.addField(byteField);
+					offset += 4;					
+					break;
+				case ASCII:
+					data = new byte[field_length];
+					if(field_length <= 4) {
+						rin.seek(offset);
+						rin.readFully(data, 0, field_length);
+					}						
+					else {
+						rin.seek(offset);
+						rin.seek(rin.readInt());
+						rin.readFully(data, 0, field_length);
+					}
+					TiffField<String> ascIIField = new ASCIIField(tag, new String(data, "UTF-8"));
+					tiffIFD.addField(ascIIField);
+					offset += 4;	
+					break;
+				case SHORT:
+				case SSHORT:
+					short[] sdata = new short[field_length];
+					if(field_length == 1) {
+					  rin.seek(offset);
+					  sdata[0] = rin.readShort();
+					  offset += 4;
+					} else if (field_length == 2) {
+						rin.seek(offset);
+						sdata[0] = rin.readShort();
+						offset += 2;
+						rin.seek(offset);
+						sdata[1] = rin.readShort();
+						offset += 2;
+					} else {
+						rin.seek(offset);
+						int toOffset = rin.readInt();
+						offset += 4;
+						for (int j = 0; j  <field_length; j++){
+							rin.seek(toOffset);
+							sdata[j] = rin.readShort();
+							toOffset += 2;
+						}
+					}
+					TiffField<short[]> shortField = null;
+					if(ftype == FieldType.SSHORT) {
+						shortField = new SShortField(tag, sdata);
+					} else {
+						shortField = new ShortField(tag, sdata);
+					}
+					tiffIFD.addField(shortField);
+					break;
+				case LONG:
+				case SLONG:
+					int[] ldata = new int[field_length];
+					if(field_length == 1) {
+					  rin.seek(offset);
+					  ldata[0] = rin.readInt();
+					  offset += 4;
+					} else {
+						rin.seek(offset);
+						int toOffset = rin.readInt();
+						offset += 4;
+						for (int j=0;j<field_length; j++){
+							rin.seek(toOffset);
+							ldata[j] = rin.readInt();
+							toOffset += 4;
+						}
+					}
+					TiffField<int[]> longField = null;
+					if(ftype == FieldType.SLONG) {
+						longField = new SLongField(tag, ldata);
+					} else {
+						longField = new LongField(tag, ldata);
+					}
+					tiffIFD.addField(longField);
+					
+					if ((ftag == TiffTag.EXIF_SUB_IFD) && (ldata[0]!= 0)) {
+						try { // If something bad happens, we skip the sub IFD
+							readIFD(tiffIFD, TiffTag.EXIF_SUB_IFD, ExifTag.class, rin, null, ldata[0]);
+						} catch(Exception e) {
+							tiffIFD.removeField(TiffTag.EXIF_SUB_IFD);
+							e.printStackTrace();
+						}
+					} else if ((ftag == TiffTag.GPS_SUB_IFD) && (ldata[0] != 0)) {
+						try {
+							readIFD(tiffIFD, TiffTag.GPS_SUB_IFD, GPSTag.class, rin, null, ldata[0]);
+						} catch(Exception e) {
+							tiffIFD.removeField(TiffTag.GPS_SUB_IFD);
+							e.printStackTrace();
+						}
+					} else if((ftag == ExifTag.EXIF_INTEROPERABILITY_OFFSET) && (ldata[0] != 0)) {
+						try {
+							readIFD(tiffIFD, ExifTag.EXIF_INTEROPERABILITY_OFFSET, InteropTag.class, rin, null, ldata[0]);
+						} catch(Exception e) {
+							tiffIFD.removeField(ExifTag.EXIF_INTEROPERABILITY_OFFSET);
+							e.printStackTrace();
+						}
+					} else if (ftag == TiffTag.SUB_IFDS) {						
+						for(int ifd = 0; ifd < ldata.length; ifd++) {
+							try {
+								readIFD(tiffIFD, TiffTag.SUB_IFDS, TiffTag.class, rin, null, ldata[0]);
+							} catch(Exception e) {
+								tiffIFD.removeField(TiffTag.SUB_IFDS);
+								e.printStackTrace();
+							}
+						}
+					}				
+					break;
+				case FLOAT:
+					float[] fdata = new float[field_length];
+					if(field_length == 1) {
+					  rin.seek(offset);
+					  fdata[0] = rin.readFloat();
+					  offset += 4;
+					} else {
+						rin.seek(offset);
+						int toOffset = rin.readInt();
+						offset += 4;
+						for (int j=0;j<field_length; j++){
+							rin.seek(toOffset);
+							fdata[j] = rin.readFloat();
+							toOffset += 4;
+						}
+					}
+					TiffField<float[]> floatField = new FloatField(tag, fdata);
+					tiffIFD.addField(floatField);
+					
+					break;
+				case DOUBLE:
+					double[] ddata = new double[field_length];
+					rin.seek(offset);
+					int toOffset = rin.readInt();
+					offset += 4;
+					for (int j=0;j<field_length; j++){
+						rin.seek(toOffset);
+						ddata[j] = rin.readDouble();
+						toOffset += 8;
+					}
+					TiffField<double[]> doubleField = new DoubleField(tag, ddata);
+					tiffIFD.addField(doubleField);
+					
+					break;
+				case RATIONAL:
+				case SRATIONAL:
+					int len = 2*field_length;
+					ldata = new int[len];	
+					rin.seek(offset);
+					toOffset = rin.readInt();
+					offset += 4;					
+					for (int j=0;j<len; j+=2){
+						rin.seek(toOffset);
+						ldata[j] = rin.readInt();
+						toOffset += 4;
+						rin.seek(toOffset);
+						ldata[j+1] = rin.readInt();
+						toOffset += 4;
+					}
+					TiffField<int[]> rationalField = null;
+					if(ftype == FieldType.SRATIONAL) {
+						rationalField = new SRationalField(tag, ldata);
+					} else {
+						rationalField = new RationalField(tag, ldata);
+					}
+					tiffIFD.addField(rationalField);
+					
+					break;
+				case IFD:
+					ldata = new int[field_length];
+					if(field_length == 1) {
+					  rin.seek(offset);
+					  ldata[0] = rin.readInt();
+					  offset += 4;
+					} else {
+						rin.seek(offset);
+						toOffset = rin.readInt();
+						offset += 4;
+						for (int j=0;j<field_length; j++){
+							rin.seek(toOffset);
+							ldata[j] = rin.readInt();
+							toOffset += 4;
+						}
+					}
+					TiffField<int[]> ifdField = new IFDField(tag, ldata);
+					tiffIFD.addField(ifdField);
+					for(int ifd = 0; ifd < ldata.length; ifd++) {
+						readIFD(tiffIFD, TiffTag.SUB_IFDS, TiffTag.class, rin, null, ldata[0]);
+					}
+								
+					break;
+				default:
+					offset += 4;
+					break;					
+			}
+		}
+		// If this is a child IFD, add it to its parent
+		if(parent != null)
+			parent.addChild(parentTag, tiffIFD);
+		else // Otherwise, add to the main IFD list
+			list.add(tiffIFD);
+		rin.seek(offset);
+		
+		return rin.readInt();
+	}
+	
+	private static void readIFDs(IFD parent, Tag parentTag, Class<? extends Tag> tagClass, List<IFD> list, int offset, RandomAccessInputStream rin) throws IOException {
+		// Read the IFDs into a list first	
+		while (offset != 0)	{
+			offset = readIFD(parent, parentTag, tagClass, rin, list, offset);
+		}
+	}
+	
+	public static void readIFDs(List<IFD> list, RandomAccessInputStream rin) throws IOException {
+		int offset = readHeader(rin);
+		readIFDs(null, null, TiffTag.class, list, offset, rin);
+	}
+	
+	public static Map<MetadataType, Metadata> readMetadata(RandomAccessInputStream rin) throws IOException {
+		return readMetadata(rin, 0);
+	}
+	
+	public static Map<MetadataType, Metadata> readMetadata(RandomAccessInputStream rin, int pageNumber) throws IOException	{
+		Map<MetadataType, Metadata> metadataMap = new HashMap<MetadataType, Metadata>();
+
+		int offset = readHeader(rin);
+		List<IFD> ifds = new ArrayList<IFD>();
+		readIFDs(null, null, TiffTag.class, ifds, offset, rin);
+		
+		if(pageNumber < 0 || pageNumber >= ifds.size())
+			throw new IllegalArgumentException("pageNumber " + pageNumber + " out of bounds: 0 - " + (ifds.size() - 1));
+		
+		IFD currIFD = ifds.get(pageNumber);
+		TiffField<?> field = currIFD.getField(TiffTag.ICC_PROFILE); 
+		if(field != null) { // We have found ICC_Profile
+			metadataMap.put(MetadataType.ICC_PROFILE, new ICCProfile((byte[])field.getData()));
+		}
+		field = currIFD.getField(TiffTag.XMP);
+		if(field != null) { // We have found XMP
+			metadataMap.put(MetadataType.XMP, new TiffXMP((byte[])field.getData()));
+		}
+		field = currIFD.getField(TiffTag.PHOTOSHOP);
+		if(field != null) { // We have found Photoshop IRB
+			IRB irb = new IRB((byte[])field.getData());
+			metadataMap.put(MetadataType.PHOTOSHOP_IRB, irb);
+			_8BIM photoshop_8bim = irb.get8BIM(ImageResourceID.IPTC_NAA.getValue());
+			if(photoshop_8bim != null) { // If we have IPTC data inside Photoshop, keep it
+				IPTC iptc = new IPTC(photoshop_8bim.getData());
+				metadataMap.put(MetadataType.IPTC, iptc);
+			}
+		}
+		field = currIFD.getField(TiffTag.IPTC);
+		if(field != null) { // We have found IPTC data
+			IPTC iptc = (IPTC)(metadataMap.get(MetadataType.IPTC));
+			byte[] iptcData = null;
+			FieldType type = field.getType();
+			if(type == FieldType.LONG)
+				iptcData = ArrayUtils.toByteArray(field.getDataAsLong(), rin.getEndian() == IOUtils.BIG_ENDIAN);
+			else
+				iptcData = (byte[])field.getData();
+			if(iptc != null) // If we have IPTC data from IRB, consolidate it with the current data
+				iptcData = ArrayUtils.concat(iptcData, iptc.getData());
+			metadataMap.put(MetadataType.IPTC, new IPTC(iptcData));
+		}		
+		field = currIFD.getField(TiffTag.EXIF_SUB_IFD);
+		if(field != null) { // We have found EXIF SubIFD
+			metadataMap.put(MetadataType.EXIF, new TiffExif(currIFD));
+		}
+		field = currIFD.getField(TiffTag.IMAGE_SOURCE_DATA);
+		if(field != null) {
+			boolean bigEndian = (rin.getEndian() == IOUtils.BIG_ENDIAN);
+			ReadStrategy readStrategy = bigEndian?ReadStrategyMM.getInstance():ReadStrategyII.getInstance();
+			metadataMap.put(MetadataType.PHOTOSHOP_DDB, new DDB((byte[])field.getData(), readStrategy));
+		}
+		field = currIFD.getField(TiffTag.IMAGE_DESCRIPTION);
+		if(field != null) { // We have Comment
+			Comments comments = new pixy.meta.image.Comments();
+			comments.addComment(field.getDataAsString());
+			metadataMap.put(MetadataType.COMMENT, comments);
+		}
+		
+		return metadataMap;
+	}
+	
+	/**
+	 * Remove meta data from TIFF image
+	 * 
+	 * @param rin RandomAccessInputStream for the input image
+	 * @param rout RandomAccessOutputStream for the output image
+	 * @param pageNumber working page from which to remove metadata
+	 * @param metadataTypes a variable length array of MetadataType to be removed
+	 * @throws IOException
+	 * @return A map of the removed metadata
+	 */
+	public static Map<MetadataType, Metadata> removeMetadata(int pageNumber, RandomAccessInputStream rin, RandomAccessOutputStream rout, MetadataType ... metadataTypes) throws IOException {
+		return removeMetadata(new HashSet<MetadataType>(Arrays.asList(metadataTypes)), pageNumber, rin, rout);
+	}
+	
+	/**
+	 * Remove meta data from TIFF image
+	 * 
+	 * @param rin RandomAccessInputStream for the input image
+	 * @param rout RandomAccessOutputStream for the output image
+	 * @param metadataTypes a variable length array of MetadataType to be removed
+	 * @throws IOException
+	 * @return A map of the removed metadata
+	 */
+	public static Map<MetadataType, Metadata> removeMetadata(RandomAccessInputStream rin, RandomAccessOutputStream rout, MetadataType ... metadataTypes) throws IOException {
+		return removeMetadata(0, rin, rout, metadataTypes);
+	}
+	
+	/**
+	 * Remove meta data from TIFF image
+	 * 
+	 * @param pageNumber working page from which to remove EXIF and GPS data
+	 * @param rin RandomAccessInputStream for the input image
+	 * @param rout RandomAccessOutputStream for the output image
+	 * @throws IOException
+	 * @return A map of the removed metadata
+	 */
+	public static Map<MetadataType, Metadata> removeMetadata(Set<MetadataType> metadataTypes, int pageNumber, RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {
+		int offset = copyHeader(rin, rout);
+		// Read the IFDs into a list first
+		List<IFD> ifds = new ArrayList<IFD>();
+		readIFDs(null, null, TiffTag.class, ifds, offset, rin);
+		
+		// Create a map to hold all the metadata and thumbnails
+		Map<MetadataType, Metadata> metadataMap = new HashMap<MetadataType, Metadata>();
+	
+		if(pageNumber < 0 || pageNumber >= ifds.size())
+			throw new IllegalArgumentException("pageNumber " + pageNumber + " out of bounds: 0 - " + (ifds.size() - 1));
+		
+		IFD workingPage = ifds.get(pageNumber);
+		
+		TiffField<?> metadata = null;
+		
+		for(MetadataType metaType : metadataTypes) {
+			switch(metaType) {
+				case XMP:
+					TiffField<?> xmpField = workingPage.removeField(TiffTag.XMP);
+					if(xmpField != null) metadataMap.put(MetadataType.XMP, new TiffXMP((byte[])xmpField.getData()));
+					metadata = workingPage.removeField(TiffTag.PHOTOSHOP);
+					if(metadata != null) {
+						byte[] data = (byte[])metadata.getData();
+						// We only remove XMP and keep the other IRB data untouched.
+						List<_8BIM> bims = removeMetadataFromIRB(workingPage, data, ImageResourceID.XMP_METADATA);
+						if(bims.size() > 0 && xmpField == null) metadataMap.put(MetadataType.XMP, new TiffXMP(bims.get(0).getData()));
+					}
+					break;
+				case IPTC:
+					TiffField<?> iptcField = workingPage.removeField(TiffTag.IPTC);
+					if(iptcField != null) metadataMap.put(MetadataType.IPTC, new IPTC((byte[])iptcField.getData()));
+					metadata = workingPage.removeField(TiffTag.PHOTOSHOP);
+					if(metadata != null) {
+						byte[] data = (byte[])metadata.getData();
+						// We only remove IPTC_NAA and keep the other IRB data untouched.
+						List<_8BIM> bims = removeMetadataFromIRB(workingPage, data, ImageResourceID.IPTC_NAA);
+						if(bims.size() > 0 && iptcField == null) metadataMap.put(MetadataType.IPTC, new IPTC(bims.get(0).getData()));
+					}
+					break;
+				case ICC_PROFILE:
+					TiffField<?>  iccField = workingPage.removeField(TiffTag.ICC_PROFILE);
+					if(iccField != null) metadataMap.put(MetadataType.ICC_PROFILE, new ICCProfile((byte[])iccField.getData()));
+					metadata = workingPage.removeField(TiffTag.PHOTOSHOP);
+					if(metadata != null) {
+						byte[] data = (byte[])metadata.getData();
+						// We only remove ICC_PROFILE and keep the other IRB data untouched.
+						List<_8BIM> bims = removeMetadataFromIRB(workingPage, data, ImageResourceID.ICC_PROFILE);
+						if(bims.size() > 0 && iccField == null) metadataMap.put(MetadataType.ICC_PROFILE, new ICCProfile(bims.get(0).getData()));
+					}
+					break;
+				case PHOTOSHOP_IRB:
+					TiffField<?> irbField = workingPage.removeField(TiffTag.PHOTOSHOP);
+					if(irbField != null) metadataMap.put(MetadataType.PHOTOSHOP_IRB, new IRB((byte[])irbField.getData()));
+					break;
+				case EXIF:
+					TiffField<?> exifField = workingPage.removeField(TiffTag.EXIF_SUB_IFD);
+					if(exifField != null) metadataMap.put(MetadataType.EXIF, new TiffExif(workingPage));
+					workingPage.removeField(TiffTag.GPS_SUB_IFD);
+					metadata = workingPage.removeField(TiffTag.PHOTOSHOP);
+					if(metadata != null) {
+						byte[] data = (byte[])metadata.getData();
+						// We only remove EXIF and keep the other IRB data untouched.
+						removeMetadataFromIRB(workingPage, data, ImageResourceID.EXIF_DATA1, ImageResourceID.EXIF_DATA3);
+					}
+					break;
+				case COMMENT:
+					TiffField<?> commentField = workingPage.removeField(TiffTag.IMAGE_DESCRIPTION);				
+					if(commentField != null) {
+						Comments comments = new Comments();
+						comments.addComment(commentField.getDataAsString());
+						metadataMap.put(MetadataType.COMMENT, comments);
+					}
+					break;
+				default:
+			}
+		}
+		
+		offset = copyPages(ifds, offset, rin, rout);
+		int firstIFDOffset = ifds.get(0).getStartOffset();	
+
+		writeToStream(rout, firstIFDOffset);
+		
+		return metadataMap;
+	}
+	
+	/**
+	 * Remove meta data from TIFF image
+	 * 
+	 * @param metadataTypes a set of MetadataType to be removed
+	 * @param rin RandomAccessInputStream for the input image
+	 * @param rout RandomAccessOutputStream for the output image	 
+	 * @throws IOException
+	 * @return A map of the removed metadata
+	 */
+	public static Map<MetadataType, Metadata> removeMetadata(Set<MetadataType> metadataTypes, RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {
+		return removeMetadata(metadataTypes, 0, rin, rout);
+	}
+	
+	private static List<_8BIM> removeMetadataFromIRB(IFD workingPage, byte[] data, ImageResourceID ... ids) throws IOException {
+		IRB irb = new IRB(data);
+		// Shallow copy the map.
+		Map<Short, _8BIM> bimMap = new HashMap<Short, _8BIM>(irb.get8BIM());
+		List<_8BIM> bimList = new ArrayList<_8BIM>();
+		// We only remove XMP and keep the other IRB data untouched.
+		for(ImageResourceID id : ids) {
+			_8BIM bim = bimMap.remove(id.getValue());
+			if(bim != null) bimList.add(bim);
+		}
+		if(bimMap.size() > 0) {
+		   	// Write back the IRB
+			ByteArrayOutputStream bout = new ByteArrayOutputStream();
+			for(_8BIM bim : bimMap.values())
+				bim.write(bout);
+			// Add new PHOTOSHOP field
+			workingPage.addField(new ByteField(TiffTag.PHOTOSHOP.getValue(), bout.toByteArray()));
+		}
+		
+		return bimList;
+	}
+	
+	public static int retainPages(int startPage, int endPage, RandomAccessInputStream rin, RandomAccessOutputStream rout) throws IOException {
+		if(startPage < 0 || endPage < 0)
+			throw new IllegalArgumentException("Negative start or end page");
+		else if(startPage > endPage)
+			throw new IllegalArgumentException("Start page is larger than end page");
+		
+		List<IFD> list = new ArrayList<IFD>();
+	  
+		int offset = copyHeader(rin, rout);
+		
+		// Step 1: read the IFDs into a list first
+		readIFDs(null, null, TiffTag.class, list, offset, rin);		
+		// Step 2: remove pages from a multiple page TIFF
+		int pagesRetained = list.size();
+		List<IFD> newList = new ArrayList<IFD>();
+		if(startPage <= list.size() - 1)  {
+			if(endPage > list.size() - 1) endPage = list.size() - 1;
+			for(int i = endPage; i >= startPage; i--) {
+				newList.add(list.get(i)); 
+			}
+		}
+		if(newList.size() > 0) {
+			pagesRetained = newList.size();
+			list.retainAll(newList);
+		}
+		// Reset pageNumber for the existing pages
+		for(int i = 0; i < list.size(); i++) {
+			list.get(i).removeField(TiffTag.PAGE_NUMBER);
+			list.get(i).addField(new ShortField(TiffTag.PAGE_NUMBER.getValue(), new short[]{(short)i, (short)(list.size() - 1)}));
+		}
+		// End of removing pages		
+		// Step 3: copy the remaining pages
+		// 0x08 is the first write offset
+		int writeOffset = FIRST_WRITE_OFFSET;
+		offset = copyPages(list, writeOffset, rin, rout);
+		int firstIFDOffset = list.get(0).getStartOffset();
+		
+		writeToStream(rout, firstIFDOffset);
+		
+		return pagesRetained;
+	}
+	
+	// Return number of pages retained
+	public static int retainPages(RandomAccessInputStream rin, RandomAccessOutputStream rout, int... pages) throws IOException {
+		List<IFD> list = new ArrayList<IFD>();
+	  
+		int offset = copyHeader(rin, rout);
+		// Step 1: read the IFDs into a list first
+		readIFDs(null, null, TiffTag.class, list, offset, rin);		
+		// Step 2: remove pages from a multiple page TIFF
+		int pagesRetained = list.size();
+		List<IFD> newList = new ArrayList<IFD>();
+		Arrays.sort(pages);
+		for(int i = pages.length - 1; i >= 0; i--) {
+			if(pages[i] >= 0 && pages[i] < list.size())
+				newList.add(list.get(pages[i])); 
+		}
+		if(newList.size() > 0) {
+			pagesRetained = newList.size();
+			list.retainAll(newList);
+		}
+		// End of removing pages
+		// Reset pageNumber for the existing pages
+		for(int i = 0; i < list.size(); i++) {
+			list.get(i).removeField(TiffTag.PAGE_NUMBER);
+			list.get(i).addField(new ShortField(TiffTag.PAGE_NUMBER.getValue(), new short[]{(short)i, (short)(list.size() - 1)}));
+		}
+		// Step 3: copy the remaining pages
+		// 0x08 is the first write offset
+		int writeOffset = FIRST_WRITE_OFFSET;
+		offset = copyPages(list, writeOffset, rin, rout);
+		int firstIFDOffset = list.get(0).getStartOffset();
+		
+		writeToStream(rout, firstIFDOffset);
+		
+		return pagesRetained;
+	}
+	
+	public static void write(TIFFImage tiffImage, RandomAccessOutputStream rout) throws IOException {
+		RandomAccessInputStream rin = tiffImage.getInputStream();
+		int offset = writeHeader(rout);
+		offset = copyPages(tiffImage.getIFDs(), offset, rin, rout);
+		int firstIFDOffset = tiffImage.getIFDs().get(0).getStartOffset();	
+	 
+		writeToStream(rout, firstIFDOffset);
+	}
+	
+	// Return stream offset where to write actual image data or IFD	
+	private static int writeHeader(RandomAccessOutputStream rout) throws IOException {
+		// Write byte order
+		short endian = rout.getEndian();
+		rout.writeShort(endian);
+		// Write TIFF identifier
+		rout.writeShort(0x2a);
+		
+		return FIRST_WRITE_OFFSET;
+	}
+		
+	private static void writeToStream(RandomAccessOutputStream rout, int firstIFDOffset) throws IOException {
+		// Go to the place where we should write the first IFD offset
+		// and write the first IFD offset
+		rout.seek(OFFSET_TO_WRITE_FIRST_IFD_OFFSET);
+		rout.writeInt(firstIFDOffset);
+		// Dump the data to the real output stream
+		rout.seek(STREAM_HEAD);
+		rout.writeToStream(rout.getLength());
+		//rout.flush();
+	}
+}
diff --git a/src/pixy/meta/tiff/TiffExif.java b/src/pixy/meta/tiff/TiffExif.java
new file mode 100644
index 0000000..8bb6ed7
--- /dev/null
+++ b/src/pixy/meta/tiff/TiffExif.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * TiffExif.java
+ *
+ * Who   Date       Description
+ * ====  =======    =================================================
+ * WY    11Feb2015  Moved showMetadata() to Exif
+ * WY    06Feb2015  Added showMetadata()
+ * WY    03Feb2015  Initial creation
+ */
+
+package pixy.meta.tiff;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import pixy.image.tiff.IFD;
+import pixy.meta.exif.Exif;
+
+public class TiffExif extends Exif {
+
+	public TiffExif() {
+		;
+	}
+	
+	public TiffExif(IFD imageIFD) {
+		super(imageIFD);		
+	}
+	
+	/** 
+	 * Write the EXIF data to the OutputStream
+	 * 
+	 * @param os OutputStream
+	 * @throws Exception 
+	 */
+	@Override
+	public void write(OutputStream os) throws IOException {
+		ensureDataRead();
+		; // We won't write anything here
+	}
+}
diff --git a/src/pixy/meta/tiff/TiffXMP.java b/src/pixy/meta/tiff/TiffXMP.java
new file mode 100644
index 0000000..cc4e0c4
--- /dev/null
+++ b/src/pixy/meta/tiff/TiffXMP.java
@@ -0,0 +1,19 @@
+package pixy.meta.tiff;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import pixy.meta.xmp.XMP;
+
+public class TiffXMP extends XMP {
+
+	public TiffXMP(byte[] data) {
+		super(data);
+		// TODO Auto-generated constructor stub
+	}
+
+	@Override
+	public void write(OutputStream os) throws IOException {
+		// TODO Auto-generated method stub
+	}
+}
diff --git a/src/pixy/meta/xmp/XMP.java b/src/pixy/meta/xmp/XMP.java
new file mode 100644
index 0000000..8a2e9ee
--- /dev/null
+++ b/src/pixy/meta/xmp/XMP.java
@@ -0,0 +1,249 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * XMP.java
+ *
+ * Who   Date       Description
+ * ====  =========  =================================================
+ * WY    06Apr2016  Moved to new package
+ * WY    03Jul2015  Added override method getData()
+ * WY    13Mar2015  Initial creation
+ */
+
+package pixy.meta.xmp;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Collections;
+import java.util.Iterator;
+
+import org.w3c.dom.CDATASection;
+import org.w3c.dom.Comment;
+import org.w3c.dom.Document;
+import org.w3c.dom.DocumentType;
+import org.w3c.dom.Element;
+import org.w3c.dom.NamedNodeMap;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+import org.w3c.dom.ProcessingInstruction;
+import org.w3c.dom.Text;
+
+import pixy.meta.Metadata;
+import pixy.meta.MetadataEntry;
+import pixy.meta.MetadataType;
+import pixy.string.XMLUtils;
+
+public abstract class XMP extends Metadata {
+	// Fields
+	private Document xmpDocument;
+	private Document extendedXmpDocument;
+	//document contains the complete XML as a Tree.
+	private Document mergedXmpDocument;
+	private boolean hasExtendedXmp;
+	private byte[] extendedXmpData;
+	
+	private String xmp;
+	
+
+	public static void showXMP(XMP xmp) {
+		XMLUtils.showXML(xmp.getMergedDocument());
+	}
+		
+	public XMP(byte[] data) {
+		super(MetadataType.XMP, data);
+	}
+	
+	public XMP(String xmp) {
+		super(MetadataType.XMP);
+		this.xmp = xmp;
+	}
+	
+	public XMP(String xmp, String extendedXmp) {
+		super(MetadataType.XMP);
+		if(xmp == null) throw new IllegalArgumentException("Input XMP string is null");
+		this.xmp = xmp;
+		if(extendedXmp != null) { // We have ExtendedXMP
+			try {
+				setExtendedXMPData(XMLUtils.serializeToByteArray(XMLUtils.createXML(extendedXmp)));
+			} catch (IOException e) {				
+				e.printStackTrace();
+			}
+		}
+	}
+	
+	private void addNodeToEntry(Node node, MetadataEntry entry) {
+		if(node != null) {
+			switch(node.getNodeType()) {
+		        case Node.DOCUMENT_NODE: {
+		            Node child = node.getFirstChild();
+		            while(child != null) {
+		            	addNodeToEntry(child, entry);
+		            	child = child.getNextSibling();
+		            }
+		            break;
+		        } 
+		        case Node.DOCUMENT_TYPE_NODE: {
+		            DocumentType doctype = (DocumentType) node;
+		            entry.addEntry(new MetadataEntry("!DOCTYPE", doctype.getName()));
+		            break;
+		        }
+		        case Node.ELEMENT_NODE: { // Element node
+		            Element ele = (Element) node;
+		       
+		            NamedNodeMap attrs = ele.getAttributes();
+		            StringBuilder attributes = new StringBuilder();
+		            for(int i = 0; i < attrs.getLength(); i++) {
+		                Node a = attrs.item(i);
+		            	attributes.append(a.getNodeName()).append("=").append("'" + a.getNodeValue()).append("' ");
+		            }
+		            MetadataEntry element = new MetadataEntry(ele.getTagName(), attributes.toString().trim(), true);
+		            entry.addEntry(element);
+	       
+		            Node child = ele.getFirstChild();
+		            while(child != null) {
+		            	addNodeToEntry(child, element);
+		            	child = child.getNextSibling();
+		            }
+		            break;
+		        }
+		        case Node.TEXT_NODE: {
+		            Text textNode = (Text)node;
+		            String text = textNode.getData().trim();
+		            if ((text != null) && text.length() > 0)
+		                entry.addEntry(new MetadataEntry(text, ""));
+		            break;
+		        }
+		        case Node.PROCESSING_INSTRUCTION_NODE: {
+		            ProcessingInstruction pi = (ProcessingInstruction)node;
+		            entry.addEntry(new MetadataEntry("?" + pi.getTarget(), pi.getData() + "?"));
+		            break;
+		        }
+		        case Node.ENTITY_REFERENCE_NODE: {
+		        	entry.addEntry(new MetadataEntry("&" + node.getNodeName() + ";", ""));
+		            break;
+		        }
+		        case Node.CDATA_SECTION_NODE: { // Output CDATA sections
+		            CDATASection cdata = (CDATASection)node;
+		            entry.addEntry(new MetadataEntry("![CDATA[" + cdata.getData() + "]]", ""));
+		            break;
+		        }
+		        case Node.COMMENT_NODE: {
+		        	Comment c = (Comment)node;
+		        	entry.addEntry(new MetadataEntry("!--" + c.getData() + "--", ""));
+		            break;
+		        }
+		        default:
+		            break;
+			}
+		}
+	}
+
+	public byte[] getData() {
+		byte[] data = super.getData();
+		if(data != null && !hasExtendedXmp)
+			return data;
+		try {
+			return XMLUtils.serializeToByteArray(getMergedDocument());
+		} catch (IOException e) {
+			return null;
+		}
+	}
+	
+	public byte[] getExtendedXmpData() {
+		return extendedXmpData;
+	}
+	
+	public Document getExtendedXmpDocument() {
+		if(hasExtendedXmp && extendedXmpDocument == null)
+			extendedXmpDocument = XMLUtils.createXML(extendedXmpData);
+
+		return extendedXmpDocument;
+	}
+	
+	/**
+	 * Merge the standard XMP and the extended XMP DOM
+	 * <p>
+	 * This is a very expensive operation, avoid if possible
+	 * 
+	 * @return a merged Document for the entire XMP data with the GUID from the standard XMP document removed
+	 */
+	public Document getMergedDocument() {
+		if(mergedXmpDocument != null)
+			return mergedXmpDocument;
+		else if(getExtendedXmpDocument() != null) { // Merge document
+			mergedXmpDocument = XMLUtils.createDocumentNode();
+			Document rootDoc = getXmpDocument();
+			NodeList children = rootDoc.getChildNodes();
+			for(int i = 0; i< children.getLength(); i++) {
+				Node importedNode = mergedXmpDocument.importNode(children.item(i), true);
+				mergedXmpDocument.appendChild(importedNode);
+			}
+			// Remove GUID from the standard XMP
+			XMLUtils.removeAttribute(mergedXmpDocument, "rdf:Description", "xmpNote:HasExtendedXMP");
+			// Copy all the children of rdf:RDF element
+			NodeList list = extendedXmpDocument.getElementsByTagName("rdf:RDF").item(0).getChildNodes();
+			Element rdf = (Element)(mergedXmpDocument.getElementsByTagName("rdf:RDF").item(0));
+		  	for(int i = 0; i < list.getLength(); i++) {
+	    		Node curr = list.item(i);
+	    		Node newNode = mergedXmpDocument.importNode(curr, true);
+    			rdf.appendChild(newNode);
+	    	}
+	    	return mergedXmpDocument;
+		} else
+			return getXmpDocument();
+	}
+	
+	public Document getXmpDocument() {
+		ensureDataRead();		
+		return xmpDocument;
+	}
+	
+	public boolean hasExtendedXmp() {
+		return hasExtendedXmp;
+	}
+	
+	public Iterator<MetadataEntry> iterator() {
+		Document doc = getMergedDocument();
+		
+		MetadataEntry dummy = new MetadataEntry("XMP", " Document", true);
+		addNodeToEntry(doc, dummy);
+		
+		return Collections.unmodifiableCollection(dummy.getMetadataEntries()).iterator();
+	}
+	
+	public void read() throws IOException {
+		if(!isDataRead) {
+			if(xmp != null)
+				xmpDocument = XMLUtils.createXML(xmp);
+			else if(data != null)
+				xmpDocument = XMLUtils.createXML(data);
+			
+			isDataRead = true;
+		}
+	}
+	
+	public void setExtendedXMPData(byte[] extendedXmpData) {
+		this.extendedXmpData = extendedXmpData;
+		hasExtendedXmp = true;
+	}
+	
+	public void showMetadata() {
+		ensureDataRead();
+		XMLUtils.showXML(getMergedDocument());
+	}
+	
+	public abstract void write(OutputStream os) throws IOException;
+}
diff --git a/src/pixy/string/Base64.java b/src/pixy/string/Base64.java
new file mode 100644
index 0000000..58a52c5
--- /dev/null
+++ b/src/pixy/string/Base64.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.string;
+
+import pixy.util.ArrayUtils;
+
+/**
+ * A simple base64 encoding and decoding utility class. It can also
+ * encode and decode non ASII characters such as Chinese.
+ * <p>
+ * Changed decode method to remove potential problem when decoding 
+ * concatenated encoded strings.
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.01 04/18/2012
+ */
+public final class Base64
+{
+	private static final char[] base64Map =  // base64 character table
+    {
+		'A','B','C','D','E','F','G','H','I','J','K','L','M','N',
+		'O','P','Q','R','S','T','U','V','W','X','Y','Z','a','b',
+		'c','d','e','f','g','h','i','j','k','l','m','n','o','p',
+		'q','r','s','t','u','v','w','x','y','z','0','1','2','3',
+		'4','5','6','7','8','9','+','/'
+	};
+ 
+    /** 
+     * Convert the platform dependent string characters to UTF8 which can 
+     * also be done by calling the java String method getBytes("UTF-8"),
+     * but I hope to do it from the ground up.
+     */
+    private static byte[] toUTF8ByteArray(String s) 
+	{
+    	int ichar; 
+    	byte buffer[] = new byte[3*(s.length())];
+    	int index = 0;
+    	int count = 0; // Count the actual bytes in the buffer array 		 
+    	
+    	for(int i = 0; i < s.length(); i++)
+    	{
+    		ichar = s.charAt(i);
+    		// Determine the bytes for a specific character
+    		if(ichar >= 0x0080 && ichar <= 0x07FF)
+    		{
+    			buffer[index++] = (byte)((6<<5)|((ichar>>6)&31));
+    			buffer[index++] = (byte)((2<<6)|(ichar&63));
+    			count += 2;
+    		} else if(ichar >= 0x0800 && ichar <= 0x0FFFF) { // Determine the bytes for a specific character
+    			buffer[index++] = (byte)((14<<4)|((ichar>>12)&15));
+    			buffer[index++] = (byte)((2<<6)|((ichar>>6)&63));
+    			buffer[index++] = (byte)((2<<6)|(ichar&63));
+    			count += 3;
+    		} else if(ichar >= 0x0000 && ichar <= 0x007F) { // Determine the bytes for a specific character
+    			buffer[index++] = (byte)((0<<7)|(ichar&127));  
+    			count += 1;
+    		} else
+    			// Longer than 16 bit Unicode is not supported
+    			throw new RuntimeException("Unsupported encoding character length!\n");
+    	}
+         
+    	return ArrayUtils.subArray(buffer, 0, count); // Trim to size
+	}
+
+    public static String encode(String s)
+    {
+	 	 byte buf[] = toUTF8ByteArray(s);
+	     return encode(buf);
+    }
+
+	public static String encode(byte buf[]) 
+    {     
+         StringBuffer sb = new StringBuffer();
+         String padder = ""; 
+			 
+         if( buf.length == 0 )  return "" ; 
+       
+         // Cope with less than 3 bytes conditions at the end of buf 
+		 switch(buf.length%3)
+         {
+         	case 1: 
+         	{ 
+         		padder += base64Map[((buf[buf.length-1] >>> 2) & 63)];
+         		padder += base64Map[((buf[buf.length-1] << 4) & 63)];     
+         		padder += "==";
+         		break;
+         	}      
+         	case 2:
+         	{
+         		padder += base64Map[( buf[buf.length-2] >>> 2) & 63];
+         		padder += base64Map[(((buf[buf.length-2] << 4)&63)) | (((buf[buf.length-1] >>>4) & 63))];          
+         		padder += base64Map[( buf[buf.length-1] << 2) & 63];          
+         		padder += "=";
+         		break;
+         	}
+         	default:
+         		break;
+         }           
+
+         int temp = 0;
+         int index = 0;
+          
+		 // Encode buf.length-buf.length%3 bytes which must be a multiply of 3
+         for( int i = 0; i < (buf.length-(buf.length % 3)) ;)
+         {
+        	 // Get three bytes and encode them to four base64 characters
+        	 temp = ((buf[i++] << 16)&0xFF0000)|((buf[i++] << 8)&0xFF00)|(buf[i++]&0xFF) ;
+        	 index = (temp >> 18) & 63 ;        
+        	 sb.append(base64Map[index]);
+        	 if(sb.length()%76 == 0) // A Base64 encoded line is no longer than 76 characters
+        		 sb.append('\n');
+  
+        	 index = (temp >> 12 ) & 63 ;
+        	 sb.append(base64Map[index]);
+        	 if(sb.length()%76 == 0)
+        		 sb.append('\n');
+           
+        	 index = (temp >> 6) & 63 ;
+        	 sb.append(base64Map[index]);
+        	 if(sb.length()%76 == 0)
+        		 sb.append('\n');
+
+        	 index = temp & 63;
+        	 sb.append(base64Map[index]);
+        	 if(sb.length()%76 == 0)
+        		 sb.append('\n');
+         }
+
+         sb.append(padder);  // Add the remaining one or two bytes
+         return sb.toString(); 
+    }
+
+    public static String decode(String s) throws Exception
+    {
+     	 byte buf[] = decodeToByteArray(s) ;
+	     return new String(buf,"UTF-8") ;
+    }
+	
+	public static byte[] decodeToByteArray(String s) throws Exception
+    {
+         //byte hold[];
+	     if( s.length() == 0 )  return null ; 
+         byte buf[] = s.getBytes("iso-8859-1") ;
+         byte debuf[] = new byte[buf.length*3/4] ;
+         byte tempBuf[] = new byte[4] ;
+         int index = 0;
+         int index1 = 0;
+         int temp;
+	     //int count=0;
+         //int count1=0;
+		 
+		 // Decode to byte array
+         for(int i = 0; i < buf.length; i++)
+         {
+             if(buf[i] >= 65 && buf[i] < 91)
+            	 tempBuf[index++] = (byte)(buf[i] - 65);
+             else if(buf[i] >= 97 && buf[i] < 123)
+            	 tempBuf[index++] = (byte)(buf[i] - 71);
+             else if(buf[i] >= 48 && buf[i] < 58)
+            	 tempBuf[index++] = (byte)(buf[i] + 4);
+             else if(buf[i] == '+')
+            	 tempBuf[index++] = 62;
+             else if(buf[i] == '/')
+            	 tempBuf[index++] = 63;
+             else if(buf[i] == '=') {
+            	 tempBuf[index++] = 0;
+            	 //count1++;
+			 } else { // Discard line breaks and other non-significant characters
+				 if(buf[i] == '\n' || buf[i] == '\r' || buf[i] == ' ' || buf[i] == '\t')
+					 continue;
+				 throw new RuntimeException("Illegal character found in encoded string!");
+             }
+             
+             if(index == 4)
+             { 
+               temp = ((tempBuf[0] << 18)) | ((tempBuf[1] << 12)) | ((tempBuf[2] << 6)) | (tempBuf[3]) ;
+               debuf[index1++] = (byte)(temp >> 16) ;
+               debuf[index1++] = (byte)((temp >> 8) & 255) ;
+               debuf[index1++] = (byte)(temp & 255) ;
+               //count += 3;
+			   index = 0;
+             }
+         }
+	     //hold = new byte[count - count1];
+         //System.arraycopy(debuf, 0, hold, 0, count - count1); //trim to size
+         //return hold;
+		 return debuf;
+    }
+}
diff --git a/src/pixy/string/StringUtils.java b/src/pixy/string/StringUtils.java
new file mode 100644
index 0000000..4a74f10
--- /dev/null
+++ b/src/pixy/string/StringUtils.java
@@ -0,0 +1,1003 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * StringUtils.java
+ *
+ * Who   Date       Description
+ * ====  =========  ==============================================================
+ * WY    03May2015  Added rationalToString()
+ * WY    04Mar2015  Added toHexString()
+ * WY    04Mar2015  Added generateMD5()
+ * WY    07Feb201   Added decimalToDMS() and DMSToDecimal()
+ * WY    23Jan2015  Moved XML related methods to XMLUtils
+ * WY    10Jan2015  Added showXML() and printNode() to show XML document
+ * WY    28Dec2014  Added isInCharset() to test if a String can be encoded with
+ * 					certain character set.
+ */
+
+package pixy.string;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.Charset;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.text.DecimalFormat;
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+import java.util.regex.*;
+
+/**
+ * String utility class  
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 09/18/2012
+ */
+public class StringUtils {
+	
+	private static final char[] HEXES = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'};
+	
+	/**
+	 * Formats byte array.
+	 * 
+	 * @param bytes an array of byte.
+	 * @return a hex string representation of the byte array.
+	 */
+	public static String byteArrayToHexString(byte[] bytes) {	    
+	    return byteArrayToHexString(bytes, 0, bytes.length);
+	}
+	
+	public static String byteArrayToHexString(byte[] bytes, int offset, int length) {		
+		if ( bytes == null )
+			return null;
+		
+		if(bytes.length == 0) return "[]";
+	    
+	    if(offset < 0 || offset >= bytes.length)
+	    	throw new IllegalArgumentException("Offset out of array bound!");
+	    
+	    int endOffset = offset + Math.min(length, bytes.length);
+		 
+	    if(endOffset > bytes.length)
+	    	length = bytes.length - offset;
+	    
+	    StringBuilder hex = new StringBuilder(5*length + 2);	    
+	    hex.append("[");
+	    
+	    for (int i = offset; i < endOffset; i++) {
+	    	hex.append("0x").append(HEXES[(bytes[i] & 0xf0) >> 4])
+	         .append(HEXES[bytes[i] & 0x0f]).append(",");
+	    }
+	    
+	    // Remove the last ","
+	    if(hex.length() > 1)
+	    	hex.deleteCharAt(hex.length()-1);
+	    
+	    if(endOffset < bytes.length)
+	    	hex.append(" ..."); // Partial output
+	    
+	    hex.append("]");
+	    
+	    return hex.toString();
+	}
+	
+	public static String byteToHexString(byte b) {
+	    return String.format("0x%02X ", b);
+	}
+	
+	/**
+	 * Capitalizes the first character of the words in a string.
+	 * 
+	 * @param s the input string
+	 * @return a string with the first character of all words capitalized
+	 */
+	public static String capitalize(String s) {   
+		StringBuffer myStringBuffer = new StringBuffer();
+		Pattern p = Pattern.compile("\\b(\\w)(\\w*)");
+		Matcher m = p.matcher(s);
+		
+        while (m.find()) {
+			if(!Character.isUpperCase(m.group().charAt(0)))
+               m.appendReplacement(myStringBuffer, m.group(1).toUpperCase()+"$2");
+        }
+        
+        return m.appendTail(myStringBuffer).toString();
+	}
+	
+	public static String capitalizeFully(String s) {   
+		return capitalize(s.toLowerCase());
+	}
+	
+	public static String concat(Iterable<? extends CharSequence> strings, String delimiter) {
+        int capacity = 0;
+        int delimLength = delimiter.length();
+
+        Iterator<? extends CharSequence> iter = strings.iterator();
+        
+        while (iter.hasNext()) {
+        	CharSequence next = iter.next();
+        	
+        	if(!isNullOrEmpty(next))
+        		capacity += next.length() + delimLength;
+        }
+
+        StringBuilder buffer = new StringBuilder(capacity);
+        iter = strings.iterator();
+        
+        while (iter.hasNext()) {
+            CharSequence next = iter.next();
+            
+            if(!isNullOrEmpty(next)) {
+            	buffer.append(next);
+            	buffer.append(delimiter);
+            }
+        }
+        
+        int lastIndexOfDelimiter = buffer.lastIndexOf(delimiter);
+        buffer.delete(lastIndexOfDelimiter, buffer.length());
+        
+        return buffer.toString();
+    }
+	
+	public static String concat(String first, String second) {
+		if(first == null) return second;
+		if(second == null) return first;
+		
+		StringBuilder sb = new StringBuilder(first.length() + second.length());
+		sb.append(first);
+		sb.append(second);
+		
+		return sb.toString();
+	}
+	
+	public static String concat(String first, String... strings) {
+		StringBuilder sb;
+		
+		if(first != null) sb = new StringBuilder(first);
+		else sb = new StringBuilder();
+		
+		for (String s: strings) {		
+			if(!isNullOrEmpty(s))
+				sb.append(s);
+	 	}
+		
+		return sb.toString();
+	}
+		
+	public static <T extends CharSequence> String concat(T[] strings, String delimiter) {
+        int capacity = 0;
+        int delimLength = delimiter.length();
+        
+        for (T value : strings) {
+        	if(!isNullOrEmpty(value))
+        		capacity += value.length() + delimLength;
+        }
+        
+        StringBuilder buffer = new StringBuilder(capacity);
+              
+        for (T value : strings) {
+        	if(!isNullOrEmpty(value)) {
+        		buffer.append(value);
+            	buffer.append(delimiter);
+        	}
+        }
+        
+        int lastIndexOfDelimiter = buffer.lastIndexOf(delimiter);
+        buffer.delete(lastIndexOfDelimiter, buffer.length());
+        
+        return buffer.toString();
+    }
+	
+	/**
+	 * Regular expression version of the String contains method.
+	 * If used with a match from start or match from end regular expression,
+	 * it becomes the regular expression version of the {@link String#
+	 * startsWith(String prefix)} or {@link String#endsWith(String suffix)}
+	 * methods.
+	 * 
+	 * @param input the input string
+	 * @param regex the regular expression to which this string is to be matched
+	 * @return true if a match is found, otherwise false
+	 */
+	public static boolean contains(String input, String regex) {
+		Pattern p = Pattern.compile(regex);
+		Matcher m = p.matcher(input);
+        
+		if (m.find()) {
+			return true;
+        }
+		
+		return false;
+	}
+	
+	// From http://stackoverflow.com/questions/15547329/how-to-prettily-format-gps-data-in-java-android
+	// Input a double latitude or longitude in the decimal format
+	public static String decimalToDMS(double coord) {
+		String output;
+		int degrees, minutes, seconds;
+	    // gets the modulus the coordinate divided by one (MOD1).
+	    // in other words gets all the numbers after the decimal point.
+	    // e.g. mod = 87.728056 % 1 == 0.728056
+	    //
+	    // next get the integer part of the coord. In other words the, whole number part.
+	    // e.g. intPart = 87
+	    double mod = coord % 1;
+	    degrees = (int)coord;
+	    // next times the MOD1 of degrees by 60 so we can find the integer part for minutes.
+	    // get the MOD1 of the new coord to find the numbers after the decimal point.
+	    // e.g. coord = 0.728056 * 60 == 43.68336
+	    //      mod = 43.68336 % 1 == 0.68336
+	    //
+	    // next get the value of the integer part of the coord.
+	    // e.g. intPart = 43
+	    coord = mod * 60;
+	    mod = coord % 1;
+	    minutes = (int)coord;
+	    //do the same again for minutes
+	    //e.g. coord = 0.68336 * 60 == 41.0016
+	    //e.g. intPart = 41
+	    coord = mod * 60;
+	    seconds = (int)coord;
+	    //Standard output of D°M'S"
+	    output = degrees + '\u00B0' + minutes + "'" + seconds + "\"";
+
+	    return output;
+	}
+
+	/**
+	 * Converts DMS to decimal 
+	 *
+	 * Input: latitude or longitude in the DMS format ( example: N 43° 36' 15.894")
+	 * @param hemisphereOUmeridien => {W,E,S,N}
+	 * @return latitude or longitude in decimal format
+	 */
+	public double DMSToDecimal(String hemisphereOUmeridien, double degrees, double minutes, double seconds) {
+		double LatOrLon = 0;
+		double sign = 1.0;
+
+		if((hemisphereOUmeridien == "W")||(hemisphereOUmeridien == "S")) {
+			sign = -1.0;
+		}              
+		
+		LatOrLon = sign*(Math.floor(degrees) + Math.floor(minutes)/60.0 + seconds/3600.0);
+
+		return LatOrLon;               
+    }
+	
+	/**
+	 * From www.javapractices.com EscapeChars.java
+	 * 
+	 * @param url URL string to be encoded
+	 * @return a encoded URL string
+	 */	
+	public static String encodeURL(String url) {
+		String result = null;
+	    
+		try {
+	       result = URLEncoder.encode(url, "UTF-8");
+	    }
+	    catch (UnsupportedEncodingException ex){
+	       throw new RuntimeException("UTF-8 not supported", ex);
+	    }
+	    
+		return result;
+	}
+		
+	/**
+	 * Escapes HTML reserved characters and other characters which might cause Cross Site Scripting
+	 * (XSS) hacks
+	 *
+	 * The following table comes from www.javapractice.com EscapeChars.java
+	 * 
+	 * <P>The following characters are replaced with corresponding HTML character entities:
+	 * 
+     * <table border='1' cellpadding='3' cellspacing='0'>
+     *  <tr><th> Character </th><th>Replacement</th></tr>
+     *  <tr><td> < </td><td> &lt; </td></tr>
+     *  <tr><td> > </td><td> &gt; </td></tr>
+     *  <tr><td> & </td><td> &amp; </td></tr>
+     *  <tr><td> " </td><td> &quot;</td></tr>
+     *  <tr><td> \t </td><td> &#009;</td></tr>
+     *  <tr><td> ! </td><td> &#033;</td></tr>
+     *  <tr><td> # </td><td> &#035;</td></tr>
+     *  <tr><td> $ </td><td> &#036;</td></tr>
+     *  <tr><td> % </td><td> &#037;</td></tr>
+     *  <tr><td> ' </td><td> &#039;</td></tr>
+     *  <tr><td> ( </td><td> &#040;</td></tr> 
+     *  <tr><td> ) </td><td> &#041;</td></tr>
+     *  <tr><td> * </td><td> &#042;</td></tr>
+     *  <tr><td> + </td><td> &#043; </td></tr>
+     *  <tr><td> , </td><td> &#044; </td></tr>
+     *  <tr><td> - </td><td> &#045; </td></tr>
+     *  <tr><td> . </td><td> &#046; </td></tr>
+     *  <tr><td> / </td><td> &#047; </td></tr>
+     *  <tr><td> : </td><td> &#058;</td></tr>
+     *  <tr><td> ; </td><td> &#059;</td></tr>
+     *  <tr><td> = </td><td> &#061;</td></tr>
+     *  <tr><td> ? </td><td> &#063;</td></tr>
+     *  <tr><td> @ </td><td> &#064;</td></tr>
+     *  <tr><td> [ </td><td> &#091;</td></tr>
+     *  <tr><td> \ </td><td> &#092;</td></tr>
+     *  <tr><td> ] </td><td> &#093;</td></tr>
+     *  <tr><td> ^ </td><td> &#094;</td></tr>
+     *  <tr><td> _ </td><td> &#095;</td></tr>
+     *  <tr><td> ` </td><td> &#096;</td></tr>
+     *  <tr><td> { </td><td> &#123;</td></tr>
+     *  <tr><td> | </td><td> &#124;</td></tr>
+     *  <tr><td> } </td><td> &#125;</td></tr>
+     *  <tr><td> ~ </td><td> &#126;</td></tr>
+     * </table>
+     *
+	 * @return a string with the specified characters replaced by HTML entities
+	 */
+	public static String escapeHTML(String input) {
+		Iterator<Character> itr = stringIterator(input);
+		StringBuilder result = new StringBuilder();		
+		
+		while (itr.hasNext())
+		{
+			Character c = itr.next();
+			
+			switch (c)
+			{				
+				case '<':
+					result.append("&lt;");
+					break;
+				case '>':
+					result.append("&gt;");
+					break;
+				case '&':
+					result.append("&amp;");
+					break;
+				case '"':
+					result.append("&quot;");
+					break;
+				case '\t':
+					result.append("&#009;");
+					break;
+				case '!':
+					result.append("&#033;");
+					break;
+				case '#':
+					result.append("&#035;");
+					break;
+				case '$':
+					result.append("&#036;");
+					break;
+				case '%':
+					result.append("&#037;");
+					break;
+				case '\'':
+					result.append("&#039;");
+					break;
+				case '(':
+					result.append("&#040;");
+					break;
+				case ')':
+					result.append("&#041;");
+					break;
+				case '*':
+					result.append("&#042;");
+					break;
+				case '+':
+					result.append("&#043;");
+					break;
+				case ',':
+					result.append("&#044;");
+					break;
+				case '-':
+					result.append("&#045;");
+					break;
+				case '.':
+					result.append("&#046;");
+					break;
+				case '/':
+					result.append("&#047;");
+					break;
+				case ':':
+					result.append("&#058;");
+					break;
+				case ';':
+					result.append("&#059;");
+					break;
+				case '=':
+					result.append("&#061;");
+					break;
+				case '?':
+					result.append("&#063;");
+					break;
+				case '@':
+					result.append("&#064;");
+					break;
+				case '[':
+					result.append("&#091;");
+					break;
+				case '\\':
+					result.append("&#092;");
+					break;
+				case ']':
+					result.append("&#093;");
+					break;
+				case '^':
+					result.append("&#094;");
+					break;
+				case '_':
+					result.append("&#095;");
+					break;
+				case '`':
+					result.append("&#096;");
+					break;
+				case '{':
+					result.append("&#123;");
+					break;
+				case '|':
+					result.append("&#124;");
+					break;
+				case '}':
+					result.append("&#125;");
+					break;
+				case '~':
+					result.append("&#126;");
+					break;
+				default:
+					result.append(c);
+			}
+		}
+		
+		return result.toString();
+	}
+	
+	/**
+	 * Replaces "&" with its entity "&amp;" to make it a valid HTML link
+	 * 
+	 * @param queryString a URL string with a query string attached
+	 * @return a valid URL string to be used as a link
+	 */
+	public static String escapeQueryStringAmp(String queryString) {
+		return queryString.replace("&", "&amp;");
+	}
+	
+	public static String escapeRegex(String input) {
+		Iterator<Character> itr = stringIterator(input);
+		StringBuilder result = new StringBuilder();		
+		
+		while (itr.hasNext())
+		{
+			Character c = itr.next();
+			
+			switch (c)
+			{
+				case '.':
+				case '^':
+				case '$':
+				case '*':
+				case '+':
+				case '?':
+				case '(':
+				case ')':
+				case '[':
+				case '{':
+					result.append("\\").append(c);
+					break;
+				case '\\':
+					result.append("\\\\");
+				    break;
+				default:
+					result.append(c);
+			}
+		}
+		
+		return result.toString();
+	}
+	
+	/**
+	 * Generate MD5 digest from a byte array
+	 * 
+	 * @param message byte array to generate MD5
+	 * @return MD5 string
+	 */
+	public static String generateMD5(byte[] message) {
+		MessageDigest md = null;
+		try {
+			md = MessageDigest.getInstance("MD5");
+		} catch (NoSuchAlgorithmException e) {
+			throw new RuntimeException("No such algorithm: MD5");
+		}
+		
+		return toHexString(md.digest(message));
+    }
+   
+	public static String intToHexString(int value) {
+		StringBuilder buffer = new StringBuilder(10);
+		
+		buffer.append("0x");		
+		
+		buffer.append(HEXES[(value & 0x0000000F)]);
+		buffer.append(HEXES[(value & 0x000000F0) >>> 4]);
+		buffer.append(HEXES[(value & 0x00000F00) >>> 8]);
+		buffer.append(HEXES[(value & 0x0000F000) >>> 12]);
+		buffer.append(HEXES[(value & 0x000F0000) >>> 16]);
+		buffer.append(HEXES[(value & 0x00F00000) >>> 20]);
+		buffer.append(HEXES[(value & 0x0F000000) >>> 24]);
+		buffer.append(HEXES[(value & 0xF0000000) >>> 28]);
+		
+		return buffer.toString();
+	}
+
+	public static String intToHexStringMM(int value) {
+		
+		StringBuilder buffer = new StringBuilder(10);
+		
+		buffer.append("0x");
+		
+		buffer.append(HEXES[(value & 0xF0000000) >>> 28]);
+		buffer.append(HEXES[(value & 0x0F000000) >>> 24]);
+		buffer.append(HEXES[(value & 0x00F00000) >>> 20]);
+		buffer.append(HEXES[(value & 0x000F0000) >>> 16]);
+		buffer.append(HEXES[(value & 0x0000F000) >>> 12]);
+		buffer.append(HEXES[(value & 0x00000F00) >>> 8]);
+		buffer.append(HEXES[(value & 0x000000F0) >>> 4]);
+		buffer.append(HEXES[(value & 0x0000000F)]);
+		
+		return buffer.toString();
+	}
+	
+	public static boolean isInCharset(String input, String encoding) {
+		Charset charset = null;
+		try {
+			// May throw different unchecked exceptions
+			charset = Charset.forName(encoding);
+		} catch(Exception ex) {
+			ex.printStackTrace();
+			return false;
+		}
+	    // Convert input into byte array and encode it again using the
+	    // same character set and see if we get the same string
+	    String output = new String(input.getBytes(charset), charset);
+	    
+	    return output.equals(input);
+	}
+	
+	/**
+	 * Checks if a string is null, empty, or consists only of white spaces
+	 * 
+	 * @param str the input CharSequence to check
+	 * @return true if the input string is null, empty, or contains only white
+	 * spaces, otherwise false
+	 */
+	public static boolean isNullOrEmpty(CharSequence str) {
+		return ((str == null) || (str.length() == 0));
+	}
+	
+	/**
+	 * Formats TIFF long data field.
+	 * 
+	 * @param data an array of int.
+	 * @param unsigned true if the int value should be treated as unsigned,
+	 * 		  otherwise false 
+	 * @return a string representation of the int array.
+	 */
+	public static String longArrayToString(int[] data, boolean unsigned) {
+		
+		return longArrayToString(data, 0, data.length, unsigned);
+	}
+	
+	public static String longArrayToString(int[] data, int offset, int length, boolean unsigned) {
+		if ( data == null ) {
+		      return null;
+		}
+			
+		if(data.length == 0) return "[]";
+	    
+	    if(offset < 0 || offset >= data.length)
+	    	throw new IllegalArgumentException("Offset out of array bound!");
+	    
+	    int endOffset = offset + Math.min(length, data.length);
+		 
+	    if(endOffset > data.length)
+	    	length = data.length - offset;
+	    
+	    StringBuilder longs = new StringBuilder();	    
+	    longs.append("[");
+		    
+	    for (int i = offset; i < endOffset; i++)
+		{
+	    	if(unsigned) {
+				// Convert it to unsigned integer
+				longs.append(data[i]&0xffffffffL);
+			} else {
+				longs.append(data[i]);
+			}
+			longs.append(",");
+		}
+	    
+	    // Remove the last ","
+	    if(longs.length() > 1)
+	    	longs.deleteCharAt(longs.length()-1);
+	    
+	    if(endOffset < data.length)
+	    	longs.append(" ..."); // Partial output
+	    
+	    longs.append("]");
+	    
+	    return longs.toString();	    
+	}
+	
+	public static boolean parseBoolean(String s) {
+		return Boolean.parseBoolean(s);
+	}
+	
+	public static byte parseByte(String s) {
+		return Byte.parseByte(s);
+	}
+	
+	public static byte parseByte(String s, int radix) {
+		return Byte.parseByte(s, radix);
+	}
+	
+	public static double parseDouble(String s) {
+		return Double.parseDouble(s);
+	}
+	
+	public static float parseFloat(String s) {
+		return Float.parseFloat(s);
+	}
+	
+	public static int parseInt(String s) {
+		return Integer.parseInt(s);
+	}
+	
+	public static int parseInt(String s, int radix) {
+		return Integer.parseInt(s, radix);
+	}		
+	
+	public static long parseLong(String s) {
+		return Long.parseLong(s);
+	}
+	
+	public static long parseLong(String s, int radix) {
+		return Long.parseLong(s, radix);
+	}
+	
+	public static short parseShort(String s) {
+		return Short.parseShort(s);
+	}
+	
+	public static short parseShort(String s, int radix) {
+		return Short.parseShort(s, radix);
+	}	
+	
+	public static String quoteRegexReplacement(String replacement)
+	{
+		return Matcher.quoteReplacement(replacement);
+	}
+	
+	/**
+	 * Formats TIFF rational data field.
+	 * 
+	 * @param data an array of int.
+	 * @param unsigned true if the int value should be treated as unsigned,
+	 * 		  otherwise false 
+	 * @return a string representation of the int array.
+	 */
+	public static String rationalArrayToString(int[] data, boolean unsigned) {
+		if(data.length%2 != 0)
+			throw new IllegalArgumentException("Data length is odd number, expect even!");
+
+		StringBuilder rational = new StringBuilder();
+		rational.append("[");
+		
+		for (int i=0; i<data.length; i+=2)
+		{
+			long  numerator = data[i], denominator = data[i+1];
+			
+			//if(denominator == 0) throw new ArithmeticException("Divided by zero");
+			
+			if (unsigned) {
+				// Converts it to unsigned integer
+				numerator = (data[i]&0xffffffffL);
+				denominator = (data[i+1]&0xffffffffL);
+			}
+			
+			rational.append(numerator);			
+			rational.append("/");
+			rational.append(denominator);
+			
+			rational.append(",");
+		}
+		
+		rational.deleteCharAt(rational.length()-1);
+		rational.append("]");
+		
+		return rational.toString();
+	}
+	
+	public static String rationalToString(DecimalFormat df, boolean unsigned, int ... rational) {
+		if(rational.length < 2) throw new IllegalArgumentException("Input data length is too short");
+		if(rational[1] == 0) throw new ArithmeticException("Divided by zero");
+		
+		long numerator = rational[0];
+		long denominator= rational[1];
+		
+		if (unsigned) {
+			// Converts it to unsigned integer
+			numerator = (numerator&0xffffffffL);
+			denominator = (denominator&0xffffffffL);
+		}
+		
+		return df.format(1.0*numerator/denominator);
+	}
+	
+	/**
+	 * Replaces the last occurrence of the string represented by the regular expression
+	 *  
+	 * @param input input string
+ 	 * @param regex the regular expression to which this string is to be matched
+	 * @param replacement the string to be substituted for the match
+	 * @return the resulting String
+	 */
+	public static String replaceLast(String input, String regex, String replacement) {
+		return input.replaceAll(regex+"(?!.*"+regex+")", replacement); // Using negative look ahead
+	}
+	
+	public static String reverse(String s) {
+		if(s == null) return null;
+		
+		return new StringBuilder(s).reverse().toString();
+	}
+	
+	// This method will not work for surrogate pairs
+	public static String reverse2(String s) {
+		if(s == null) return null;
+		
+		int i, len = s.length();
+	    StringBuilder dest = new StringBuilder(len);
+
+	    for (i = (len - 1); i >= 0; i--)
+	       dest.append(s.charAt(i));
+	    
+	    return dest.toString();
+	}
+	
+	public static String reverse(String str, String delimiter) {	
+		if(isNullOrEmpty(delimiter)) {
+			return str;
+		}
+		
+		StringBuilder sb = new StringBuilder(str.length());
+		reverseIt(str, delimiter, sb);
+		
+		return sb.toString();
+	}
+	
+	public static String reverse2(String str, String delimiter) {
+		if(isNullOrEmpty(delimiter) || isNullOrEmpty(str) || (str.trim().length() == 0) || (str.indexOf(delimiter) < 0)) {
+			return str;
+		} 			
+	
+		String escaptedDelimiter = escapeRegex(delimiter);
+		// Keep the trailing white spaces by setting limit to -1	
+		String[] stringArray = str.split(escaptedDelimiter, -1);
+		StringBuilder sb = new StringBuilder(str.length() + delimiter.length());
+			
+		for (int i = stringArray.length-1; i >= 0; i--)
+		{
+			sb.append(stringArray[i]).append(delimiter);
+		}
+
+		return sb.substring(0, sb.lastIndexOf(delimiter));
+	}
+	
+	private static void reverseIt(String str, String delimiter, StringBuilder sb) {
+		if(isNullOrEmpty(str) || (str.trim().length() == 0) || str.indexOf(delimiter) < 0) {
+			sb.append(str);
+			return;
+		}	
+		// Recursion
+		reverseIt(str.substring(str.indexOf(delimiter)+delimiter.length()), delimiter, sb);
+		sb.append(delimiter);
+		sb.append(str.substring(0, str.indexOf(delimiter)));
+	}
+		
+	public static String reverseWords(String s) {
+		String[] stringArray = s.split("\\b");
+		StringBuilder sb = new StringBuilder(s.length());
+		
+		for (int i = stringArray.length-1; i >= 0; i--)
+		{
+			sb.append(stringArray[i]);
+		}
+		
+		return sb.toString();
+	}
+	
+	/**
+	 * Formats TIFF short data field.
+	 * 
+	 * @param data an array of short.
+	 * @param unsigned true if the short value should be treated as unsigned,
+	 * 		  otherwise false 
+	 * @return a string representation of the short array.
+	 */
+	public static String shortArrayToString(short[] data, boolean unsigned) {
+		return shortArrayToString(data, 0, data.length, unsigned);
+	}
+	
+	public static String shortArrayToString(short[] data, int offset, int length, boolean unsigned) {
+		if ( data == null ) {
+		      return null;
+		}
+			
+		if(data.length == 0) return "[]";
+	    
+	    if(offset < 0 || offset >= data.length)
+	    	throw new IllegalArgumentException("Offset out of array bound!");
+	    
+	    int endOffset = offset + Math.min(length, data.length);
+		 
+	    if(endOffset > data.length)
+	    	length = data.length - offset;
+	    
+	    StringBuilder shorts = new StringBuilder();	    
+	    shorts.append("[");
+		    
+	    for (int i = offset; i < endOffset; i++)
+		{
+			if(unsigned) {
+				// Convert it to unsigned short
+				shorts.append(data[i]&0xffff);
+			} else {
+				shorts.append(data[i]);
+			}
+			shorts.append(",");
+		}
+	    
+	    // Remove the last ","
+	    if(shorts.length() > 1)
+	    	shorts.deleteCharAt(shorts.length()-1);
+	    
+	    if(endOffset < data.length)
+	    	shorts.append(" ..."); // Partial output
+	    
+	    shorts.append("]");
+		
+		return shorts.toString();
+	}
+	
+	public static String shortToHexString(short value) {
+		StringBuilder buffer = new StringBuilder(6);
+		
+		buffer.append("0x");		
+		
+		buffer.append(HEXES[(value & 0x000F)]);
+		buffer.append(HEXES[(value & 0x00F0) >>> 4]);
+		buffer.append(HEXES[(value & 0x0F00) >>> 8]);
+		buffer.append(HEXES[(value & 0xF000) >>> 12]);
+		
+		return buffer.toString();
+	}
+	
+	public static String shortToHexStringMM(short value) {
+		
+		StringBuilder buffer = new StringBuilder(6);
+		
+		buffer.append("0x");
+		
+		buffer.append(HEXES[(value & 0xF000) >>> 12]);
+		buffer.append(HEXES[(value & 0x0F00) >>> 8]);
+		buffer.append(HEXES[(value & 0x00F0) >>> 4]);
+		buffer.append(HEXES[(value & 0x000F)]);
+		
+		return buffer.toString();
+	}
+	
+	/**
+	 *  Converts stack trace to string
+	 */
+    public static String stackTraceToString(Throwable e) {  
+        StringWriter sw = new StringWriter();  
+        e.printStackTrace(new PrintWriter(sw));
+        
+        return sw.toString();   
+    }
+	
+	/**
+	 * A read-only String iterator from stackoverflow.com
+	 * 
+	 * @param string input string to be iterated
+	 * @return an iterator for the input string
+	 */
+	public static Iterator<Character> stringIterator(final String string) {
+		// Ensure the error is found as soon as possible.
+		if (string == null)
+			throw new NullPointerException();
+
+		return new Iterator<Character>() {
+			private int index = 0;
+
+			public boolean hasNext() {
+				return index < string.length();
+			}
+
+			public Character next() {
+				/*
+				 * Throw NoSuchElementException as defined by the Iterator contract,
+				 * not IndexOutOfBoundsException.
+				 */
+				if (!hasNext())
+					throw new NoSuchElementException();
+				return string.charAt(index++);
+			}
+
+			public void remove() {
+				throw new UnsupportedOperationException();
+			}
+		};
+	}
+	
+	public static String toHexString(byte[] bytes) {
+		return toHexString(bytes, 0, bytes.length);
+	}
+	
+	/**
+	 * Convert byte array to hex string
+	 * 
+	 * @param bytes input byte array
+	 * @param offset start offset
+	 * @param length number of items to include
+	 *  
+	 * @return a hex string representation for the byte array without 0x prefix
+	 */
+	public static String toHexString(byte[] bytes, int offset, int length) {		
+		if ( bytes == null )
+			return null;
+	    
+		if(bytes.length == 0) return "";
+	    
+	    if(offset < 0 || offset >= bytes.length)
+	    	throw new IllegalArgumentException("Offset out of array bound!");
+	    
+	    int endOffset = offset + Math.min(length, bytes.length);
+		 
+	    if(endOffset > bytes.length)
+	    	length = bytes.length - offset;
+	    
+	    StringBuilder hex = new StringBuilder(5*length + 2);	    
+		    
+	    for (int i = offset; i < endOffset; i++) {
+	    	hex.append(HEXES[(bytes[i] & 0xf0) >> 4])
+	         .append(HEXES[bytes[i] & 0x0f]);
+	    }
+	    
+	    return hex.toString();
+	}
+	
+	public static String toUTF16BE(byte[] data, int start, int length) {
+		String retVal = "";
+		
+		 try {
+			 retVal = new String(data, start, length, "UTF-16BE");
+		 } catch (UnsupportedEncodingException e) {
+			 e.printStackTrace();
+		 }
+		
+		 return retVal;		 
+	}
+	
+	private StringUtils(){} // Prevents instantiation	
+}
diff --git a/src/pixy/string/XMLUtils.java b/src/pixy/string/XMLUtils.java
new file mode 100644
index 0000000..ef193b0
--- /dev/null
+++ b/src/pixy/string/XMLUtils.java
@@ -0,0 +1,427 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * StringUtils.java
+ *
+ * Who   Date       Description
+ * ====  =========  =====================================================
+ * WY    29Apr2015  Renamed findAttribute() to getAttribute()
+ * WY    09Apr2015  Added null check to findAttribute()
+ * WY    03Mar2015  Added serializeToString() and serializeToByteArray()
+ * WY    27Feb2015  Added findAttribute() and removeAttribute()
+ * WY    23Jan2015  Initial creation - moved XML related methods to here
+ */
+
+
+package pixy.string;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.util.Iterator;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Result;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerConfigurationException;
+import javax.xml.transform.TransformerException;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.w3c.dom.CDATASection;
+import org.w3c.dom.Comment;
+import org.w3c.dom.Document;
+import org.w3c.dom.DocumentType;
+import org.w3c.dom.Element;
+import org.w3c.dom.NamedNodeMap;
+import org.w3c.dom.Node;
+import org.w3c.dom.ProcessingInstruction;
+import org.w3c.dom.Text;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+import org.w3c.dom.NodeList;
+import org.w3c.dom.ls.DOMImplementationLS;
+import org.w3c.dom.ls.LSOutput;
+import org.w3c.dom.ls.LSSerializer;
+
+public class XMLUtils {
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(XMLUtils.class);
+		
+	public static void addChild(Node parent, Node child) {
+		parent.appendChild(child);
+	}
+	
+	public static void addText(Document doc, Node parent, String data) {
+		parent.appendChild(doc.createTextNode(data));
+	}
+	
+	// Create an empty Document node
+	public static Document createDocumentNode() {
+		DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+	    DocumentBuilder builder = null;
+	    
+		try {
+			builder = factory.newDocumentBuilder();
+		} catch (ParserConfigurationException e) {
+			e.printStackTrace();
+		}
+		
+		return builder.newDocument();
+	}
+	
+	public static Node createElement(Document doc, String tagName) {
+		return doc.createElement(tagName);
+	}
+	
+	public static Document createXML(byte[] xml) {
+		//Get the DOM Builder Factory
+		DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+		//Get the DOM Builder
+		DocumentBuilder builder = null;
+		try {
+			builder = factory.newDocumentBuilder();
+		} catch (ParserConfigurationException e) {
+			e.printStackTrace();
+		}
+		//Load and Parse the XML document
+		//document contains the complete XML as a Tree.
+		Document document = null;
+		try {
+			document = builder.parse(new ByteArrayInputStream(xml));			
+		} catch (SAXException e) {
+			e.printStackTrace();
+		} catch (IOException e) {
+			e.printStackTrace();
+		}
+		
+		return document;
+	}
+	
+	public static Document createXML(String xml) {
+		//Get the DOM Builder Factory
+		DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+		//Get the DOM Builder
+		DocumentBuilder builder = null;
+		try {
+			builder = factory.newDocumentBuilder();
+		} catch (ParserConfigurationException e) {
+			e.printStackTrace();
+		}
+		//Load and Parse the XML document
+		//document contains the complete XML as a Tree.
+		Document document = null;
+		InputSource source = new InputSource(new StringReader(xml));
+		try {
+			document = builder.parse(source);			
+		} catch (SAXException e) {
+			e.printStackTrace();
+		} catch (IOException e) {
+			e.printStackTrace();
+		}
+		
+		return document;		 
+	}
+	
+	public static String escapeXML(String input) 
+	{
+		Iterator<Character> itr = StringUtils.stringIterator(input);
+		StringBuilder result = new StringBuilder();		
+		
+		while (itr.hasNext())
+		{
+			Character c = itr.next();
+			
+			switch (c)
+			{
+				case '"':
+					result.append("&quot;");
+					break;
+				case '\'':
+					result.append("&apos;");
+					break;
+				case '<':
+					result.append("&lt;");
+					break;
+				case '>':
+					result.append("&gt;");
+					break;
+				case '&':
+					result.append("&amp;");
+					break;
+				default:
+					result.append(c);
+			}
+		}
+		
+		return result.toString();
+	}
+	
+	// Retrieve the first non-empty, non-null attribute value for the attribute name
+	public static String getAttribute(Document doc, String tagName, String attribute) {
+		// Sanity check
+		if(doc == null || tagName == null || attribute == null)	return "";
+		
+		NodeList nodes = doc.getElementsByTagName(tagName);
+		
+		for(int i = 0; i < nodes.getLength(); i++) {
+			String attr = ((Element)nodes.item(i)).getAttribute(attribute);
+			if(!StringUtils.isNullOrEmpty(attr))
+				return attr;
+		}
+		
+		return "";
+	}
+	
+	public static void insertLeadingPI(Document doc, String target, String data) {
+		Element element = doc.getDocumentElement();
+	    ProcessingInstruction pi = doc.createProcessingInstruction(target, data);
+	    element.getParentNode().insertBefore(pi, element);
+	}
+	
+	public static void insertTrailingPI(Document doc, String target, String data) {
+		Element element = doc.getDocumentElement();
+	    ProcessingInstruction pi = doc.createProcessingInstruction(target, data);
+	    element.getParentNode().appendChild(pi);
+	}
+		
+	public static void printNode(Node node, String increment) {
+		StringBuilder xmlTree = new StringBuilder();
+		String indent = "";
+		// Construct the XML tree
+		print(node, indent, increment, xmlTree);
+		// Log the XML tree
+		LOGGER.info("\n{}", xmlTree);
+	}
+	
+	private static void print(Node node, String indent, String increment, StringBuilder stringBuilder) {
+		if(node != null) {
+			if(indent == null) indent = "";  
+			switch(node.getNodeType()) {
+		        case Node.DOCUMENT_NODE: {
+		            Node child = node.getFirstChild();
+		            while(child != null) {
+		            	print(child, indent, increment, stringBuilder);
+		            	child = child.getNextSibling();
+		            }
+		            break;
+		        } 
+		        case Node.DOCUMENT_TYPE_NODE: {
+		            DocumentType doctype = (DocumentType) node;
+		            stringBuilder.append("<!DOCTYPE " + doctype.getName() + ">\n");
+		            break;
+		        }
+		        case Node.ELEMENT_NODE: { // Element node
+		            Element ele = (Element) node;
+		            stringBuilder.append(indent + "<" + ele.getTagName());
+		            NamedNodeMap attrs = ele.getAttributes(); 
+		            for(int i = 0; i < attrs.getLength(); i++) {
+		                Node a = attrs.item(i);
+		                stringBuilder.append(" " + a.getNodeName() + "='" + 
+		                          escapeXML(a.getNodeValue()) + "'");
+		            }
+		            stringBuilder.append(">\n");
+	
+		            Node child = ele.getFirstChild();
+		            while(child != null) {
+		            	print(child, indent + increment, increment, stringBuilder);
+		            	child = child.getNextSibling();
+		            }
+	
+		            stringBuilder.append(indent + "</" + ele.getTagName() + ">\n");
+		            break;
+		        }
+		        case Node.TEXT_NODE: {
+		            Text textNode = (Text)node;
+		            String text = textNode.getData().trim();
+		            if ((text != null) && text.length() > 0)
+		                stringBuilder.append(indent + escapeXML(text) + "\n");
+		            break;
+		        }
+		        case Node.PROCESSING_INSTRUCTION_NODE: {
+		            ProcessingInstruction pi = (ProcessingInstruction)node;
+		            stringBuilder.append(indent + "<?" + pi.getTarget() +
+		                               " " + pi.getData() + "?>\n");
+		            break;
+		        }
+		        case Node.ENTITY_REFERENCE_NODE: {
+		            stringBuilder.append(indent + "&" + node.getNodeName() + ";\n");
+		            break;
+		        }
+		        case Node.CDATA_SECTION_NODE: { // Output CDATA sections
+		            CDATASection cdata = (CDATASection)node;
+		            stringBuilder.append(indent + "<" + "![CDATA[" + cdata.getData() +
+		                        "]]" + ">\n");
+		            break;
+		        }
+		        case Node.COMMENT_NODE: {
+		        	Comment c = (Comment)node;
+		            stringBuilder.append(indent + "<!--" + c.getData() + "-->\n");
+		            break;
+		        }
+		        default:
+		            LOGGER.error("Unknown node: " + node.getClass().getName());
+		            break;
+			}
+		}
+	}
+	
+	// Retrieve and remove the first non-empty, non-null attribute value for the attribute name
+	public static String removeAttribute(Document doc, String tagName, String attribute) {
+		NodeList nodes = doc.getElementsByTagName(tagName);
+		String retVal = "";
+		
+		for(int i = 0; i < nodes.getLength(); i++) {
+			Element ele = (Element)nodes.item(i);
+			String attr = ele.getAttribute(attribute);
+			
+			if(!StringUtils.isNullOrEmpty(attr)) {
+				retVal = attr;
+				ele.removeAttribute(attribute);
+				break;
+			}
+		}
+		
+		return retVal;		
+	}
+	
+	public static byte[] serializeToByteArray(Document doc) throws IOException {
+		TransformerFactory tFactory = TransformerFactory.newInstance();
+		Transformer transformer = null;
+		try {
+			transformer = tFactory.newTransformer();
+		} catch (TransformerConfigurationException e) {
+			throw new IOException("Unable to serialize XML document");
+		}
+		transformer.setOutputProperty(OutputKeys.INDENT, "no");
+		transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
+		String encoding = doc.getInputEncoding();
+		if(encoding == null) encoding = "UTF-8";
+		transformer.setOutputProperty(OutputKeys.ENCODING, encoding);
+		DOMSource source = new DOMSource(doc);
+		ByteArrayOutputStream out = new ByteArrayOutputStream();
+		Result result = new StreamResult(out);
+		try {
+			transformer.transform(source, result);
+		} catch (TransformerException e) {
+			throw new IOException("Unable to serialize XML document");
+		}
+		
+		return out.toByteArray();
+	}
+	
+	/**
+	 * Serialize XML Document to string using DOM Level 3 Load/Save
+	 * 
+	 * @param doc XML Document
+	 * @return String representation of the Document
+	 * @throws IOException
+	 */
+	public static String serializeToStringLS(Document doc) throws IOException {
+		return serializeToStringLS(doc, doc);
+	}
+	
+	/**
+	 * Serialize XML Document to string using DOM Level 3 Load/Save
+	 * 
+	 * @param doc XML Document
+	 * @param node the Node to serialize
+	 * @return String representation of the Document
+	 * @throws IOException
+	 */
+	public static String serializeToStringLS(Document doc, Node node) throws IOException {
+		String encoding = doc.getInputEncoding();
+        if(encoding == null) encoding = "UTF-8";
+        
+        return serializeToStringLS(doc, node, encoding);
+	}
+	
+	/**
+	 * Serialize XML Node to string
+	 * <p>
+	 * Note: this method is supposed to be faster than the Transform version but the output control
+	 * is limited. If node is Document node, it will output XML PI which sometimes we want to avoid.
+	 * 
+	 * @param doc XML document
+	 * @param node Node to be serialized
+	 * @param encoding encoding for the output
+	 * @return String representation of the Document
+	 * @throws IOException
+	 */
+	public static String serializeToStringLS(Document doc, Node node, String encoding) throws IOException {
+		DOMImplementationLS domImpl = (DOMImplementationLS) doc.getImplementation();
+        LSSerializer lsSerializer = domImpl.createLSSerializer();
+        LSOutput output = domImpl.createLSOutput();
+        output.setEncoding(encoding);
+        StringWriter writer = new StringWriter();
+        output.setCharacterStream(writer);
+        lsSerializer.write(node, output);
+        writer.flush();
+        
+        return writer.toString();
+	}
+	
+	public static String serializeToString(Document doc) throws IOException {
+		String encoding = doc.getInputEncoding();
+		if(encoding == null) encoding = "UTF-8";
+		
+		return serializeToString(doc, encoding);
+	}
+	
+	/**
+	 * Serialize XML Document to string using Transformer
+	 * 
+	 * @param node the XML node (and the subtree rooted at this node) to be serialized
+	 * @param encoding encoding for the XML document
+	 * @return String representation of the Document
+	 * @throws IOException
+	 */
+	public static String serializeToString(Node node, String encoding) throws IOException {
+		TransformerFactory tFactory = TransformerFactory.newInstance();
+		Transformer transformer = null;
+		try {
+			transformer = tFactory.newTransformer();
+		} catch (TransformerConfigurationException e) {
+			throw new IOException("Unable to serialize XML document");
+		}
+		transformer.setOutputProperty(OutputKeys.INDENT, "no");
+		transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
+		transformer.setOutputProperty(OutputKeys.ENCODING, encoding);
+		DOMSource source = new DOMSource(node);
+		StringWriter writer = new StringWriter();
+        Result result = new StreamResult(writer);
+		try {
+			transformer.transform(source, result);
+		} catch (TransformerException e) {
+			throw new IOException("Unable to serialize XML document");
+		}		
+	    writer.flush();
+	    
+        return writer.toString();
+	}
+	
+	public static void showXML(Document document) {
+		printNode(document, "     ");
+	}
+}
diff --git a/src/pixy/util/ArrayUtils.java b/src/pixy/util/ArrayUtils.java
new file mode 100644
index 0000000..2f35e63
--- /dev/null
+++ b/src/pixy/util/ArrayUtils.java
@@ -0,0 +1,1353 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * ArrayUtils.java
+ *
+ * Who   Date       Description
+ * ====  =========  ======================================================================
+ * WY    14Jun2015  Bug fix for toNBits() to use long data type internally
+ * WY    04Jun2015  Rewrote all concatenation related methods
+ * WY    02Jun2015  Bug fix for generic concatenate methods
+ * WY    06Apr2015  Added reverse(byte[]) to reverse byte array elements
+ * WY    06Jan2015  Added reverse() to reverse array elements
+ * WY    10Dec2014  Moved reverseBits() from IMGUtils to here along with BIT_REVERSE_TABLE
+ * WY    08Dec2014  Fixed bug for flipEndian() with more than 32 bit sample data 
+ * WY    07Dec2014  Changed method names for byte array to other array types conversion
+ * WY    07Dec2014  Added new methods to work with floating point TIFF images
+ * WY    03Dec2014  Added byteArrayToFloatArray() and byteArrayToDoubleArray()
+ * WY    25Nov2014  Added removeDuplicates() to sort and remove duplicates from int arrays
+ * WY    12Nov2014  Changed the argument sequence for flipEndian()
+ * WY    11Nov2014  Changed flipEndian() to include scan line stride to skip bits
+ * WY    11Nov2014  Added toNBits() to convert byte array to nBits data unit
+ * WY    28Oct2014  Added flipEndian() to work with TIFTweaker mergeTiffImagesEx()
+ */
+
+package pixy.util;
+
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.lang.reflect.Array;
+import java.nio.ByteOrder;
+import java.util.AbstractList;
+import java.nio.ByteBuffer;
+import java.nio.DoubleBuffer;
+import java.nio.FloatBuffer;
+import java.nio.IntBuffer;
+import java.nio.LongBuffer;
+import java.nio.ShortBuffer;
+
+/**
+ * Array utility class 
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 09/18/2012
+ */
+public class ArrayUtils 
+{
+	// Bit mask 0 - 32 bits (inclusive)
+	private static final int[] MASK = { 0x000,
+					    0x1, 0x3, 0x7, 0xf,
+					    0x1f, 0x3f, 0x7f, 0xff,
+					    0x1ff, 0x3ff, 0x7ff, 0xfff,
+				        0x1fff, 0x3fff, 0x7fff, 0xffff,
+				        0x1ffff, 0x3ffff, 0x7ffff, 0xfffff,
+				        0x1fffff, 0x3fffff, 0x7fffff, 0xffffff,
+				        0x1ffffff, 0x3ffffff, 0x7ffffff, 0xfffffff,
+				        0x1fffffff, 0x3fffffff, 0x7fffffff, 0xffffffff // 32 bits				    
+					};
+	
+	// Bit reverse table to work with TIFF FillOrder field.
+	private static final byte[] BIT_REVERSE_TABLE =	{
+	   (byte)0x00, (byte)0x80, (byte)0x40, (byte)0xc0, (byte)0x20, (byte)0xa0, (byte)0x60, (byte)0xe0,
+	   (byte)0x10, (byte)0x90, (byte)0x50, (byte)0xd0, (byte)0x30, (byte)0xb0, (byte)0x70, (byte)0xf0,
+	   (byte)0x08, (byte)0x88, (byte)0x48, (byte)0xc8, (byte)0x28, (byte)0xa8, (byte)0x68, (byte)0xe8,
+	   (byte)0x18, (byte)0x98, (byte)0x58, (byte)0xd8, (byte)0x38, (byte)0xb8, (byte)0x78, (byte)0xf8,
+	   (byte)0x04, (byte)0x84, (byte)0x44, (byte)0xc4, (byte)0x24, (byte)0xa4, (byte)0x64, (byte)0xe4,
+	   (byte)0x14, (byte)0x94, (byte)0x54, (byte)0xd4, (byte)0x34, (byte)0xb4, (byte)0x74, (byte)0xf4,
+	   (byte)0x0c, (byte)0x8c, (byte)0x4c, (byte)0xcc, (byte)0x2c, (byte)0xac, (byte)0x6c, (byte)0xec,
+	   (byte)0x1c, (byte)0x9c, (byte)0x5c, (byte)0xdc, (byte)0x3c, (byte)0xbc, (byte)0x7c, (byte)0xfc,
+	   (byte)0x02, (byte)0x82, (byte)0x42, (byte)0xc2, (byte)0x22, (byte)0xa2, (byte)0x62, (byte)0xe2,
+	   (byte)0x12, (byte)0x92, (byte)0x52, (byte)0xd2, (byte)0x32, (byte)0xb2, (byte)0x72, (byte)0xf2,
+	   (byte)0x0a, (byte)0x8a, (byte)0x4a, (byte)0xca, (byte)0x2a, (byte)0xaa, (byte)0x6a, (byte)0xea,
+	   (byte)0x1a, (byte)0x9a, (byte)0x5a, (byte)0xda, (byte)0x3a, (byte)0xba, (byte)0x7a, (byte)0xfa,
+	   (byte)0x06, (byte)0x86, (byte)0x46, (byte)0xc6, (byte)0x26, (byte)0xa6, (byte)0x66, (byte)0xe6,
+	   (byte)0x16, (byte)0x96, (byte)0x56, (byte)0xd6, (byte)0x36, (byte)0xb6, (byte)0x76, (byte)0xf6,
+	   (byte)0x0e, (byte)0x8e, (byte)0x4e, (byte)0xce, (byte)0x2e, (byte)0xae, (byte)0x6e, (byte)0xee,
+	   (byte)0x1e, (byte)0x9e, (byte)0x5e, (byte)0xde, (byte)0x3e, (byte)0xbe, (byte)0x7e, (byte)0xfe,
+	   (byte)0x01, (byte)0x81, (byte)0x41, (byte)0xc1, (byte)0x21, (byte)0xa1, (byte)0x61, (byte)0xe1,
+	   (byte)0x11, (byte)0x91, (byte)0x51, (byte)0xd1, (byte)0x31, (byte)0xb1, (byte)0x71, (byte)0xf1,
+	   (byte)0x09, (byte)0x89, (byte)0x49, (byte)0xc9, (byte)0x29, (byte)0xa9, (byte)0x69, (byte)0xe9,
+	   (byte)0x19, (byte)0x99, (byte)0x59, (byte)0xd9, (byte)0x39, (byte)0xb9, (byte)0x79, (byte)0xf9,
+	   (byte)0x05, (byte)0x85, (byte)0x45, (byte)0xc5, (byte)0x25, (byte)0xa5, (byte)0x65, (byte)0xe5,
+	   (byte)0x15, (byte)0x95, (byte)0x55, (byte)0xd5, (byte)0x35, (byte)0xb5, (byte)0x75, (byte)0xf5,
+	   (byte)0x0d, (byte)0x8d, (byte)0x4d, (byte)0xcd, (byte)0x2d, (byte)0xad, (byte)0x6d, (byte)0xed,
+	   (byte)0x1d, (byte)0x9d, (byte)0x5d, (byte)0xdd, (byte)0x3d, (byte)0xbd, (byte)0x7d, (byte)0xfd,
+	   (byte)0x03, (byte)0x83, (byte)0x43, (byte)0xc3, (byte)0x23, (byte)0xa3, (byte)0x63, (byte)0xe3,
+	   (byte)0x13, (byte)0x93, (byte)0x53, (byte)0xd3, (byte)0x33, (byte)0xb3, (byte)0x73, (byte)0xf3,
+	   (byte)0x0b, (byte)0x8b, (byte)0x4b, (byte)0xcb, (byte)0x2b, (byte)0xab, (byte)0x6b, (byte)0xeb,
+	   (byte)0x1b, (byte)0x9b, (byte)0x5b, (byte)0xdb, (byte)0x3b, (byte)0xbb, (byte)0x7b, (byte)0xfb,
+	   (byte)0x07, (byte)0x87, (byte)0x47, (byte)0xc7, (byte)0x27, (byte)0xa7, (byte)0x67, (byte)0xe7,
+	   (byte)0x17, (byte)0x97, (byte)0x57, (byte)0xd7, (byte)0x37, (byte)0xb7, (byte)0x77, (byte)0xf7,
+	   (byte)0x0f, (byte)0x8f, (byte)0x4f, (byte)0xcf, (byte)0x2f, (byte)0xaf, (byte)0x6f, (byte)0xef,
+	   (byte)0x1f, (byte)0x9f, (byte)0x5f, (byte)0xdf, (byte)0x3f, (byte)0xbf, (byte)0x7f, (byte)0xff
+	};
+	
+	// From Effective Java 2nd Edition. 
+   	public static List<Integer> asList(final int[] a) 
+   	{
+   		if (a == null)
+   			throw new NullPointerException();
+   		return new AbstractList<Integer>() {// Concrete implementation built atop skeletal implementation
+   			public Integer get(int i) {
+   				return a[i]; 
+   			}
+   			
+   			@Override public Integer set(int i, Integer val) {
+   				int oldVal = a[i];
+   				a[i] = val; 
+   				return oldVal;
+   			}
+   			public int size() {
+   				return a.length;
+   			}
+   		};
+   	}
+	
+	public static void bubbleSort(int[] array) {
+	    int n = array.length;
+	    boolean doMore = true;
+	    
+	    while (doMore) {
+	        n--;
+	        doMore = false;  // assume this is our last pass over the array
+	    
+	        for (int i=0; i < n; i++) {
+	            if (array[i] > array[i+1]) {
+	                // exchange elements
+	                int temp = array[i];
+	                array[i] = array[i+1];
+	                array[i+1] = temp;
+	                doMore = true;  // after an exchange, must look again 
+	            }
+	        }
+	    }
+	}
+	
+	public static <T extends Comparable<? super T>> void bubbleSort(T[] array) {
+	    int n = array.length;
+	    boolean doMore = true;
+	    
+	    while (doMore) {
+	        n--;
+	        doMore = false;  // assume this is our last pass over the array
+	    
+	        for (int i = 0; i < n; i++) {
+	            if (array[i].compareTo(array[i+1]) > 0) {
+	                // exchange elements
+	                T temp = array[i];
+	                array[i] = array[i+1];
+	                array[i+1] = temp;
+	                doMore = true;  // after an exchange, must look again 
+	            }
+	        }
+	    }
+	}
+	
+	/**
+     * Since Set doesn't allow duplicates add() return false
+     * if we try to add duplicates into Set and this property
+     * can be used to check if array contains duplicates.
+     * 
+     * @param input input array
+     * @return true if input array contains duplicates, otherwise false.
+     */
+    public static <T> boolean checkDuplicate(T[] input) {
+        Set<T> tempSet = new HashSet<T>();
+        
+        for (T str : input) {
+            if (!tempSet.add(str)) {
+                return true;
+            }
+        }
+        
+        return false;
+    }
+	
+	public static byte[] concat(byte[] first, byte[]... rest) {
+  	 	if(first == null) {
+			throw new IllegalArgumentException("Firt element is null");
+		}
+  	 	if(rest.length == 0) return first;
+		// Now the real stuff
+  	  	int totalLength = first.length;
+	  
+		for (byte[] array : rest) {		
+			totalLength += array.length;
+	 	}
+		
+		byte[] result = new byte[totalLength];
+	  
+		int offset = first.length;
+		
+		System.arraycopy(first, 0, result, 0, offset);
+	
+		for (byte[] array : rest) {
+			System.arraycopy(array, 0, result, offset, array.length);
+			offset += array.length;
+		}
+		
+		return result;
+	}
+	
+	/**
+	 * Type safe concatenation of arrays with upper type bound T
+	 * <p>
+	 * Note: if type parameter is not explicitly supplied, it will be inferred as the 
+	 * upper bound for the two parameters.
+	 * 
+	 * @param arrays the arrays to be concatenated
+	 * @return a concatenation of the input arrays
+	 * @throws NullPointerException if any of the input array is null
+	 */
+	public static <T> T[] concat(T[]... arrays) {
+		if(arrays.length == 0)
+			throw new IllegalArgumentException("Varargs length is zero");
+		
+		if(arrays.length == 1) return arrays[0];
+		
+		// Now the real stuff
+		int totalLength = 0;
+		// Taking advantage of the compiler type inference
+		Class<?> returnType = arrays.getClass().getComponentType().getComponentType();
+		
+		for (T[] array : arrays)	
+			totalLength += array.length;
+		
+		@SuppressWarnings("unchecked")
+		T[] result = (T[]) Array.newInstance(returnType, totalLength);
+	 
+		int offset = 0;
+		for (T[] array : arrays) {
+			System.arraycopy(array, 0, result, offset, array.length);
+			offset += array.length;
+		}
+		
+		return result;
+	}
+	
+	/** 
+	 * Type safe concatenation of arrays with upper type bound T
+	 *  
+     * @param type type bound for the concatenated array
+     * @param arrays arrays to be concatenated
+     * @return a concatenation of the arrays.
+     * @throws NullPointerException if any of the arrays to be
+     *         concatenated is null.
+   	 */
+	public static <T> T[] concat(Class<T> type, T[]... arrays) {
+		if(type == null) 
+			throw new IllegalArgumentException("Input type class is null");
+		
+		if(arrays.length == 0) { // Return a zero length array instead of null
+			@SuppressWarnings("unchecked")
+			T[] result = (T[]) Array.newInstance(type, 0);
+			
+			return result;
+		}
+		
+		// Make sure we have at least two arrays to concatenate
+		if(arrays.length == 1) return arrays[0];
+		
+		int totalLength = 0;	  
+		for (T[] array : arrays)	
+			totalLength += array.length;
+		
+		@SuppressWarnings("unchecked")
+		T[] result = (T[]) Array.newInstance(type, totalLength);
+	  
+		int offset = 0;
+		for (T[] array : arrays) {
+			System.arraycopy(array, 0, result, offset, array.length);
+			offset += array.length;
+		}
+		
+		return result;
+	}
+
+	public static int findEqualOrLess(int[] a, int key) {
+    	return findEqualOrLess(a, 0, a.length, key);
+    }
+	
+	/**
+     * Find the index of the element which is equal or less than the key.
+     * The array must be sorted in ascending order.
+     *
+     * @param a the array to be searched
+     * @param key the value to be searched for
+     * @return index of the search key or index of the element which is closest to but less than the search key or
+     * -1 is the search key is less than the first element of the array.
+     */
+    
+    public static int findEqualOrLess(int[] a, int fromIndex, int toIndex, int key) {
+    	int index = Arrays.binarySearch(a, fromIndex, toIndex, key);
+    	
+    	// -index - 1 is the insertion point if not found, so -index - 1 -1 is the less position 
+    	if(index < 0) {
+    		index = -index - 1 - 1;
+    	}
+    	
+    	// The index of the element which is either equal or less than the key
+    	return index;    	
+    }
+	
+	public static <T> int findEqualOrLess(T[] a, int fromIndex, int toIndex, T key, Comparator<? super T> c) {
+    	int index = Arrays.binarySearch(a, fromIndex, toIndex, key, c);
+    	
+    	// -index - 1 is the insertion point if not found
+    	if(index < 0) {
+    		index = -index - 1 - 1;
+    	}
+    	
+    	return index;    	
+    }
+	
+	public static <T> int findEqualOrLess(T[] a, T key, Comparator<? super T> c) {
+    	return findEqualOrLess(a, 0, a.length, key, c);
+    }
+	
+	/**
+     * Flip the endian of the input byte-compacted array
+     * 
+     * @param input the input byte array which is byte compacted
+     * @param offset the offset to start the reading input
+     * @param len number of bytes to read
+     * @param bits number of bits for the data before compaction
+     * @param scanLineStride scan line stride to skip bits
+     * @param bigEndian whether or not the input data is in big endian order
+     *
+     * @return a byte array with the endian flipped
+     */     
+   	public static byte[] flipEndian(byte[] input, int offset, int len, int bits, int scanLineStride, boolean bigEndian) {
+   		long value = 0;
+   		int bits_remain = 0;
+   		long temp_byte = 0; // Must make this long, otherwise will give wrong result for bits > 32
+   		int empty_bits = 8;
+   		
+   		byte[] output = new byte[input.length];
+   		
+   		int strideCounter = 0;
+   		
+   		int end = offset + len;
+   		int bufIndex = 0;
+    	 
+   		int temp = bits;
+    	boolean bigEndianOut = !bigEndian;
+    	
+   	  	loop:
+   	  	while(true) {  			
+   	   		
+			if(!bigEndian)
+				value = (temp_byte >> (8-bits_remain));
+			else				
+				value = (temp_byte & MASK[bits_remain]); 
+				
+			while (bits > bits_remain)
+			{
+				if(offset >= end) {
+					break loop;
+				}
+				
+				temp_byte = input[offset++]&0xff;
+				
+				if(bigEndian)
+					value = ((value<<8)|temp_byte);
+				else
+					value |= (temp_byte<<bits_remain);
+				
+				bits_remain += 8;
+			}
+			
+			bits_remain -= bits;
+			
+			if(bigEndian)
+				value = (value>>bits_remain);		
+	        
+		  	// Write bits bit length value in opposite endian	    	
+	    	if(bigEndianOut) {
+	    		temp = bits-empty_bits;
+	    		output[bufIndex] |= ((value>>temp)&MASK[empty_bits]);
+	    		
+	    		while(temp > 8)
+				{
+					output[++bufIndex] |= ((value>>(temp-8))&MASK[8]);
+					temp -= 8;
+				} 
+	    		
+	    		if(temp > 0) {
+	    			output[++bufIndex] |= ((value&MASK[temp])<<(8-temp));
+	    			temp -= 8;
+	    		}
+	       	} else { // Little endian
+	       		temp = bits;
+				output[bufIndex] |= ((value&MASK[empty_bits])<<(8-empty_bits));
+				value >>= empty_bits;
+		        temp -= empty_bits;
+		        // If the code is longer than the empty_bits
+				while(temp > 8) {
+					output[++bufIndex] |= (value&0xff);
+					value >>= 8;
+					temp -= 8;
+				}
+				
+		        if(temp > 0)
+				{
+		        	output[++bufIndex] |= (value&MASK[temp]);
+	    			temp -= 8;
+				}
+			}
+	    	
+	    	empty_bits = -temp;
+			
+	    	if(++strideCounter%scanLineStride == 0) {
+				empty_bits = 0;
+				bits_remain = 0;	
+			}			
+   	  	}
+   		
+		return output;
+	}
+	
+	// From http://stackoverflow.com/questions/6162651/half-precision-floating-point-in-java
+   	// returns all higher 16 bits as 0 for all results
+   	public static int fromFloat(float fval)
+   	{
+   	    int fbits = Float.floatToIntBits(fval);
+   	    int sign = fbits >>> 16 & 0x8000; // sign only
+   	    int val = (fbits & 0x7fffffff) + 0x1000; // rounded value
+
+   	    if(val >= 0x47800000) // might be or become NaN/Inf
+   	    {                     // avoid Inf due to rounding
+   	        if( (fbits & 0x7fffffff) >= 0x47800000)
+   	        {                        // is or must become NaN/Inf
+   	            if(val < 0x7f800000) // was value but too large
+   	                return sign | 0x7c00;  // make it +/-Inf
+   	            return sign | 0x7c00 |  // remains +/-Inf or NaN
+   	                (fbits & 0x007fffff) >>> 13; // keep NaN (and Inf) bits
+   	        }
+   	        return sign | 0x7bff;  // unrounded not quite Inf
+   	    }
+   	    if(val >= 0x38800000)  // remains normalized value
+   	        return sign | val - 0x38000000 >>> 13; // exp - 127 + 15
+   	    if(val < 0x33000000) // too small for subnormal
+   	        return sign;     // becomes +/-0
+   	    val = (fbits & 0x7fffffff) >>> 23;  // tmp exp for subnormal calc
+   	    return sign | ((fbits & 0x7fffff | 0x800000) // add subnormal bit
+   	         + (0x800000 >>> val - 102) // round depending on cut off
+   	      >>> 126 - val); // div by 2^(1-(exp-127+15)) and >> 13 | exp=0
+   	}
+	
+	/**
+  	 * Since nonzero-length array is always mutable, we should return
+  	 * a clone of the underlying array as BIT_REVERSE_TABLE.clone().
+  	 *
+  	 * @return the byte reverse table.
+  	 */
+  	public static byte[] getBitReverseTable() {
+  		return BIT_REVERSE_TABLE.clone();
+  	}
+	 
+	// Insertion sort
+    public static void insertionsort(int[] array) {
+	   insertionsort(array, 0, array.length - 1);
+    }
+
+	public static void insertionsort(int[] array, int start, int end) {
+	   int j;
+
+	   for (int i = start + 1; i < end + 1; i++)
+	   {
+		   
+		   int temp = array[i];
+		   for ( j = i; j > start && temp <= array[j-1]; j-- )
+		       array[j] = array[j-1];
+		   // Move temp to the right place
+		   array[j] = temp;
+	   }
+    }
+	
+	// Insertion sort
+    public static <T extends Comparable<? super T>> void insertionsort(T[] array) {
+    	insertionsort(array, 0, array.length - 1);
+    }
+    
+	// Insertion sort
+    public static <T extends Comparable<? super T>> void insertionsort(T[] array, int start, int end) {
+	   int j;
+
+	   for (int i = start + 1; i < end + 1; i++)
+	   {
+		   T temp = array[i];
+		   for ( j = i; j > start && temp.compareTo(array[j-1]) <= 0; j-- )
+		       array[j] = array[j-1];
+		   // Move temp to the right place
+		   array[j] = temp;
+	   }
+    }
+    
+    // Merge sort
+    public static void mergesort(int[] array) { 
+	   mergesort(array, new int[array.length], 0, array.length - 1);
+    }
+    
+    public static void mergesort(int[] array, int left, int right) {
+    	if(left < 0 || right > array.length - 1) throw new IllegalArgumentException("Array index out of bounds");
+        mergesort(array, new int[array.length], left, right);
+    }
+    
+    private static void mergesort(int[] array, int[] temp, int left, int right) {
+    	// check the base case
+        if (left < right) {
+          // Get the index of the element which is in the middle
+          int middle = left + (right - left) / 2;
+          // Sort the left side of the array
+          mergesort(array, temp, left, middle);
+          // Sort the right side of the array
+          mergesort(array, temp, middle + 1, right);
+          // Merge the left and the right
+          merge(array, temp, left, middle, right);
+        }
+    }
+    
+    public static <T extends Comparable<? super T>> void mergesort(T[] array) {
+    	mergesort(array, 0, array.length - 1);
+    }
+    
+    public static <T extends Comparable<? super T>> void mergesort(T[] array, int left, int right) {
+     	if(left < 0 || right > array.length - 1) throw new IllegalArgumentException("Array index out of bounds");
+        @SuppressWarnings("unchecked")
+		T[] temp = (T[]) Array.newInstance(array.getClass().getComponentType(), array.length);
+    	mergesort(array, temp, left, right);
+    }
+    
+    // Merge sort
+    private static <T extends Comparable<? super T>> void mergesort(T[] array, T[] temp, int left, int right) {
+    	// check the base case
+        if (left < right) {
+          // Get the index of the element which is in the middle
+          int middle = left + (right - left) / 2;
+          // Sort the left side of the array
+          mergesort(array, temp, left, middle);
+          // Sort the right side of the array
+          mergesort(array, temp, middle + 1, right);
+          // Merge the left and the right
+          merge(array, temp, left, middle, right);
+        }
+    }   
+    
+    private static <T extends Comparable<? super T>> void merge(T[] array, T[] temp, int left, int middle, int right) {
+    	// Copy both parts into the temporary array
+        for (int i = left; i <= right; i++) {
+          temp[i] = array[i];
+        }
+        int i = left;
+        int j = middle + 1;
+        int k = left;
+        while (i <= middle && j <= right) {
+            if (temp[i].compareTo(temp[j]) <= 0) {
+                array[k] = temp[i];
+                i++;
+            } else {
+                array[k] = temp[j];
+                j++;
+            }
+            k++;
+        }
+        while (i <= middle) {
+            array[k] = temp[i];
+            k++;
+            i++;
+        }        
+    }
+    
+    private static void merge(int[] array, int[] temp, int left, int middle, int right) {
+    	// Copy both parts into the temporary array
+        for (int i = left; i <= right; i++) {
+          temp[i] = array[i];
+        }
+        int i = left;
+        int j = middle + 1;
+        int k = left;
+        while (i <= middle && j <= right) {
+            if (temp[i] <= temp[j]) {
+                array[k] = temp[i];
+                i++;
+            } else {
+                array[k] = temp[j];
+                j++;
+            }
+            k++;
+        }
+        while (i <= middle) {
+            array[k] = temp[i];
+            k++;
+            i++;
+        }        
+    }
+    
+	/**
+	 * Packs all or part of the input byte array which uses "bits" bits to use all 8 bits.
+	 * 
+	 * @param input input byte array
+	 * @param start offset of the input array to start packing  
+	 * @param bits number of bits used by the input array
+	 * @param len number of bytes from the input to be packed
+	 * @return the packed byte array
+	 */
+	public static byte[] packByteArray(byte[] input, int start, int bits, int len) {
+		//
+		if(bits == 8) return ArrayUtils.subArray(input, start, len);
+		if(bits > 8 || bits <= 0) throw new IllegalArgumentException("Invalid value of bits: " + bits);
+		
+		byte[] packedBytes = new byte[(bits*len + 7)>>3];
+		short mask[] = {0x00, 0x01, 0x03, 0x07, 0x0f, 0x1f, 0x3f, 0x7f, 0xff};
+	    	
+		int index = 0;
+		int empty_bits = 8;
+		int end = start + len;
+		
+		for(int i = start; i < end; i++) {
+			// If we have enough space for input byte, one step operation
+			if(empty_bits >= bits) {
+				packedBytes[index] |= ((input[i]&mask[bits])<<(empty_bits-bits));
+				empty_bits -= bits;				
+				if(empty_bits == 0) {
+					index++;
+					empty_bits = 8;
+				}
+			} else { // Otherwise two step operation
+				packedBytes[index++] |= ((input[i]>>(bits-empty_bits))&mask[empty_bits]);
+				packedBytes[index] |= ((input[i]&mask[bits-empty_bits])<<(8-bits+empty_bits));
+				empty_bits += (8-bits);
+			}
+		}
+		
+		return packedBytes;
+	}
+
+   	/**
+	 * Packs all or part of the input byte array which uses "bits" bits to use all 8 bits.
+	 * <p>
+	 * We assume len is a multiplication of stride. The parameter stride controls the packing
+	 * unit length and different units <b>DO NOT</b> share same byte. This happens when packing
+	 * image data where each scan line <b>MUST</b> start at byte boundary like TIFF.
+	 * 
+	 * @param input input byte array to be packed
+	 * @param stride length of packing unit
+	 * @param start offset of the input array to start packing  
+	 * @param bits number of bits used in each byte of the input
+	 * @param len number of input bytes to be packed
+	 * @return the packed byte array
+	 */
+	public static byte[] packByteArray(byte[] input, int stride, int start, int bits, int len) {
+		//
+		if(bits == 8) return ArrayUtils.subArray(input, start, len);
+		if(bits > 8 || bits <= 0) throw new IllegalArgumentException("Invalid value of bits: " + bits);
+		
+		int bitsPerStride = bits*stride;
+		int numOfStrides = len/stride;
+		byte[] packedBytes = new byte[((bitsPerStride + 7)>>3)*numOfStrides];
+		short mask[] = {0x00, 0x01, 0x03, 0x07, 0x0f, 0x1f, 0x3f, 0x7f, 0xff};
+	    	
+		int index = 0;
+		int empty_bits = 8;
+		int end = start + len;
+		int strideCounter = 0;
+		
+		for(int i = start; i < end; i++) {
+			// If we have enough space for input byte, one step operation
+			if(empty_bits >= bits) {
+				packedBytes[index] |= ((input[i]&mask[bits])<<(empty_bits-bits));
+				empty_bits -= bits;
+			} else { // Otherwise, split the pixel between two bytes.			
+				//This will never happen for 1, 2, 4, 8 bits color depth image		
+				packedBytes[index++] |= ((input[i]>>(bits-empty_bits))&mask[empty_bits]);
+				packedBytes[index] |= ((input[i]&mask[bits-empty_bits])<<(8-bits+empty_bits));
+				empty_bits += (8-bits);
+			}
+			// Check to see if we need to move to next byte
+			if(++strideCounter%stride == 0 || empty_bits == 0) {
+				index++;
+				empty_bits = 8;			
+			}
+		}
+		
+		return packedBytes;
+	}
+    
+    // Quick sort
+    public static void quicksort(int[] array) {
+	   quicksort (array, 0, array.length - 1);
+    }
+    
+    public static void quicksort (int[] array, int start, int end) {
+	   int inner = start;
+	   int outer = end;
+	   int mid = (start + end) / 2;
+	
+	   do {
+		// work in from the start until we find a swap to the
+		// other partition is needed 
+		   while ((inner < mid) && (array[inner] <= array[mid]))
+			  inner++;                         
+	
+		// work in from the end until we find a swap to the
+		// other partition is needed
+	
+		   while ((outer > mid) && (array[outer] >= array[mid]))
+			  outer--;                          
+		
+		// if inner index <= outer index, swap elements	
+		   if (inner < mid && outer > mid) {
+		      swap(array, inner, outer);
+			  inner++;
+			  outer--;
+		   } else if (inner < mid) {
+			  swap(array, inner, mid - 1);
+			  swap(array, mid, mid - 1);
+			  mid--;
+		   } else if (outer >mid) {
+			  swap(array, outer, mid + 1);
+			  swap(array, mid, mid + 1);
+			  mid++;		
+		   }	
+	   } while (inner !=outer);
+	
+	// recursion
+	   if ((mid - 1) > start) quicksort(array, start, mid - 1);
+ 	   if (end > (mid + 1)) quicksort(array, mid + 1, end);
+    }
+    
+    // Quick sort
+    public static <T extends Comparable<? super T>> void quicksort (T[] array) {
+    	quicksort(array, 0, array.length - 1);
+    }
+    
+    // Quick sort
+    public static <T extends Comparable<? super T>> void quicksort (T[] array, int low, int high) {
+    	int i = low, j = high;
+		// Get the pivot element from the middle of the list
+		T pivot = array[low + (high-low)/2];
+
+		// Divide into two lists
+		while (i <= j) {
+			// If the current value from the left list is smaller then the pivot
+			// element then get the next element from the left list
+			while (array[i].compareTo(pivot) < 0) {
+				i++;
+			}
+			// If the current value from the right list is larger then the pivot
+			// element then get the next element from the right list
+			while (array[j].compareTo(pivot) > 0) {
+				j--;
+			}
+
+			// If we have found a values in the left list which is larger then
+			// the pivot element and if we have found a value in the right list
+			// which is smaller then the pivot element then we exchange the
+			// values.
+			// As we are done we can increase i and j
+			if (i <= j) {
+				swap(array, i, j);
+				i++;
+				j--;
+			}
+		}
+		// Recursion
+		if (low < j)
+			quicksort(array, low, j);
+		if (i < high)
+			quicksort(array, i, high);
+	}
+    
+    // Based on java2novice.com example
+    /**
+     * Remove duplicate elements from an int array
+     * 
+     * @param input input unsorted int array
+     * @return a sorted int array with unique elements
+     */
+    public static int[] removeDuplicates(int[] input) {
+        //return if the array length is less than 2
+        if(input.length < 2){
+            return input;
+        }
+      
+        // Sort the array first
+        Arrays.sort(input);        
+        
+    	int j = 0;
+        int i = 1;
+              
+        while(i < input.length){
+            if(input[i] == input[j]){
+                i++;
+            } else{
+                input[++j] = input[i++];
+            }   
+        }
+        
+        int[] output = new int[j + 1];
+        
+        System.arraycopy(input, 0, output, 0, j + 1);
+        
+        return output;
+    }
+   	
+   	// Reverse the bit order (bit sex) of a byte array
+	public static void reverseBits(byte[] input) {
+		for(int i = input.length - 1; i >= 0; i--)
+			input[i] = BIT_REVERSE_TABLE[input[i]&0xff];
+	}
+	
+	public static byte[] reverse(byte[] array) {
+		if (array == null)
+			throw new IllegalArgumentException("Input array is null");
+		int left = 0;
+		int right = array.length - 1;
+		byte tmp;
+		while (left < right) {
+			tmp = array[right];
+			array[right] = array[left];
+			array[left] = tmp;
+			left++;
+			right--;
+		}
+		
+		return array;
+	}
+
+	// Reverse the array
+	public static <T> void reverse(T[] data) {
+	    for (int left = 0, right = data.length - 1; left < right; left++, right--) {
+	        T temp = data[left];
+	        data[left]  = data[right];
+	        data[right] = temp;
+	    }
+	}
+   	
+    // Shell sort
+    public static void shellsort(int[] array) {
+    	shellsort(array, 0, array.length - 1);
+    }
+   	
+    public static void shellsort(int[] array, int start, int end) {
+    	if(start < 0 || end < 0 || start > end || end > array.length -1) throw new IllegalArgumentException("Array index out of bounds");
+    	int gap = 1;
+    	int len = end - start + 1;
+ 	    // Generate Knuth sequence 1, 4, 13, 40, 121, 364,1093, 3280, 9841 ...
+    	while(gap < len) gap = 3*gap + 1;
+    	while ( gap > 0 )
+    	{
+    		int begin = start + gap;
+    		for (int i = begin; i <= end; i++)
+    		{
+    			int temp = array[i];
+    			int j = i;
+    			while ( j >= begin && temp <= array[j - gap])
+    			{
+    				array[j] = array[j - gap];
+    				j -= gap;
+    			}
+    			array[j] = temp;
+    		}
+    		gap /= 3;
+    	}
+	}
+    
+    // Shell sort
+    public static <T extends Comparable<? super T>> void shellsort(T[] array) {
+    	shellsort(array, 0, array.length - 1);
+    }
+   	
+    // Shell sort
+    public static <T extends Comparable<? super T>> void shellsort(T[] array, int start, int end) {
+    	if(start < 0 || end < 0 || start > end || end > array.length - 1) throw new IllegalArgumentException("Array index out of bounds");
+	   	int gap = 1;
+	   	int len = end - start + 1;
+  	    // Generate Knuth sequence 1, 4, 13, 40, 121, 364,1093, 3280, 9841 ...
+	   	while(gap < len) gap = 3*gap + 1;
+	   	while ( gap > 0 )
+	   	{
+	   		int begin = start + gap;
+	   		for (int i = begin; i <= end; i++)
+	   		{
+	   			T temp = array[i];
+	   			int j = i;
+	   			while ( j >= begin && temp.compareTo(array[j - gap]) <= 0)
+	   			{
+	   				array[j] = array[j - gap];
+	   				j -= gap;
+	   			}
+	   			array[j] = temp;
+	   		}
+	   		gap /= 3;
+	   	}
+    } 	
+
+    public static byte[] subArray(byte[] src, int offset, int len) {
+		if(offset == 0 && len == src.length) return src;
+		if((offset < 0 || offset >= src.length) || (offset + len > src.length))
+			throw new IllegalArgumentException("Copy range out of array bounds");
+		byte[] dest = new byte[len];
+		System.arraycopy(src, offset, dest, 0, len);
+		
+		return dest;
+	}
+    
+    private static final void swap(int[] array, int a, int b) {
+	   int temp = array[a];
+	   array[a] = array[b];
+	   array[b] = temp;
+    }
+    
+    private static final <T> void swap(T[] array, int a, int b) {
+	   T temp = array[a];
+	   array[a] = array[b];
+	   array[b] = temp;
+    }
+    
+   	public static float[] to16BitFloatArray(byte[] data, boolean bigEndian) {
+		short[] shorts = (short[])toNBits(16, data, Integer.MAX_VALUE, bigEndian);
+		float[] floats = new float[shorts.length];
+	
+		for(int i = 0; i < floats.length; i++) {
+			floats[i] = toFloat(shorts[i]);
+		}
+		
+		return floats;
+	}
+
+    /*
+	 * Tries to convert 24 bit floating point sample to 32 bit float data.
+	 * Up to now, there has been no way to do it correctly and there might be no
+	 * correct way to do this because 24 bit is not an IEEE floating point type.
+	 * 24 bit floating point images appear too dark using this conversion.
+	 */	
+	public static float[] to24BitFloatArray(byte[] data, boolean bigEndian) {
+		int[] ints = (int[])toNBits(24, data, Integer.MAX_VALUE, bigEndian);
+		float[] floats = new float[ints.length];
+	
+		for(int i = 0; i < floats.length; i++) {
+			/**
+			int bits = ints[i]<<8;
+			int sign     = ((bits & 0x80000000) == 0) ? 1 : -1;
+	        int exponent = ((bits & 0x7f800000) >> 23);
+	        int mantissa =  (bits & 0x007fffff);
+
+	        mantissa |= 0x00800000;
+	      
+	        floats[i] = (float)(sign * mantissa * Math.pow(2, exponent-150));
+	        */
+			floats[i] = Float.intBitsToFloat(ints[i]<<8);
+		}
+		
+		return floats;
+	}
+
+    // Convert byte array to long array, then to integer array discarding the higher bits
+	public static int[] to32BitsLongArray(byte[] data, boolean bigEndian) {
+		ByteBuffer byteBuffer = ByteBuffer.wrap(data);
+		
+		if (bigEndian) {
+			byteBuffer.order(ByteOrder.BIG_ENDIAN);
+		} else {
+			byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+		}
+		
+		LongBuffer longBuf = byteBuffer.asLongBuffer();
+		long[] array = new long[longBuf.remaining()];
+		longBuf.get(array);
+		
+		int[] iArray = new int[array.length];
+		
+		int i = 0;
+		
+		for(long l : array) {
+			iArray[i++] = (int)l;
+		}
+		
+		return iArray;
+	}
+    
+    public static byte[] toByteArray(int value) {
+		return new byte[] {
+	        (byte)value,
+	        (byte)(value >>> 8),
+	        (byte)(value >>> 16),
+	        (byte)(value >>> 24)	            		            
+	        };
+	}
+    
+    public static byte[] toByteArray(int[] data, boolean bigEndian) {
+		
+		ByteBuffer byteBuffer = ByteBuffer.allocate(data.length * 4);
+		
+		if (bigEndian) {
+			byteBuffer.order(ByteOrder.BIG_ENDIAN);
+		} else {
+			byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+		}
+        
+		IntBuffer intBuffer = byteBuffer.asIntBuffer();
+        intBuffer.put(data);
+
+        byte[] array = byteBuffer.array();
+
+		return array;
+	}
+    
+  	public static byte[] toByteArray(long[] data, boolean bigEndian) {
+		
+		ByteBuffer byteBuffer = ByteBuffer.allocate(data.length * 8);
+		
+		if (bigEndian) {
+			byteBuffer.order(ByteOrder.BIG_ENDIAN);
+		} else {
+			byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+		}
+        
+		LongBuffer longBuffer = byteBuffer.asLongBuffer();
+        longBuffer.put(data);
+
+        byte[] array = byteBuffer.array();
+
+		return array;
+	}
+	
+	public static byte[] toByteArray(short value) {
+		 return new byte[] {
+				 (byte)value, (byte)(value >>> 8)};
+	}
+
+	public static byte[] toByteArray(short[] data, boolean bigEndian) {
+		
+		ByteBuffer byteBuffer = ByteBuffer.allocate(data.length * 2);
+		
+		if (bigEndian) {
+			byteBuffer.order(ByteOrder.BIG_ENDIAN);
+		} else {
+			byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+		}
+        
+		ShortBuffer shortBuffer = byteBuffer.asShortBuffer();
+        shortBuffer.put(data);
+
+        byte[] array = byteBuffer.array();
+
+		return array;
+	}
+    
+    public static byte[] toByteArrayMM(int value) {
+    	return new byte[] {
+	        (byte)(value >>> 24),
+	        (byte)(value >>> 16),
+	        (byte)(value >>> 8),
+	        (byte)value};
+	}
+
+    public static byte[] toByteArrayMM(short value) {
+		 return new byte[] {
+				 (byte)(value >>> 8), (byte)value};
+	}
+    
+    public static double[] toDoubleArray(byte[] data, boolean bigEndian) {
+		return toDoubleArray(data, 0, data.length, bigEndian);
+	}
+	
+	public static double[] toDoubleArray(byte[] data, int offset, int len, boolean bigEndian) {
+		
+		ByteBuffer byteBuffer = ByteBuffer.wrap(data, offset, len);
+		
+		if (bigEndian) {
+			byteBuffer.order(ByteOrder.BIG_ENDIAN);
+		} else {
+			byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+		}
+		
+		DoubleBuffer doubleBuf = byteBuffer.asDoubleBuffer();
+		double[] array = new double[doubleBuf.remaining()];
+		doubleBuf.get(array);
+		
+		return array;
+	}
+
+    // From http://stackoverflow.com/questions/6162651/half-precision-floating-point-in-java
+	// Converts integer to float ignores the higher 16 bits
+	public static float toFloat(int lbits) {
+		// ignores the higher 16 bits
+	    int mant = lbits & 0x03ff;            // 10 bits mantissa
+	    int exp = lbits & 0x7c00;             // 5 bits exponent
+	   
+	    if(exp == 0x7c00 ) {                  // NaN/Inf
+	        exp = 0x3fc00;                    // -> NaN/Inf
+	    } else if(exp != 0) {                 // normalized value	   
+	        exp += 0x1c000;                   // exp - 15 + 127
+	        if( mant == 0 && exp > 0x1c400)   // smooth transition
+	            return Float.intBitsToFloat(
+	            		( lbits & 0x8000) << 16
+	                    | exp << 13 | 0x3ff);
+	    } else if(mant != 0) {                // && exp==0 -> subnormal
+	    	exp = 0x1c400;                    // make it normal
+	        do {
+	            mant <<= 1;                   // mantissa * 2
+	            exp -= 0x400;                 // decrease exp by 1
+	        } while((mant & 0x400) == 0);     // while not normal
+	        mant &= 0x3ff;                    // discard subnormal bit
+	    }                                     // else +/-0 -> +/-0
+	   
+	    return Float.intBitsToFloat(          // combine all parts
+	        ( lbits & 0x8000 ) << 16          // sign  << ( 31 - 15 )
+	        | ( exp | mant ) << 13 );         // value << ( 23 - 10 )
+	}
+
+    public static float[] toFloatArray(byte[] data, boolean bigEndian) {
+		return toFloatArray(data, 0, data.length, bigEndian);
+	}
+
+    public static float[] toFloatArray(byte[] data, int offset, int len, boolean bigEndian) {
+		
+		ByteBuffer byteBuffer = ByteBuffer.wrap(data, offset, len);
+		
+		if (bigEndian) {
+			byteBuffer.order(ByteOrder.BIG_ENDIAN);
+		} else {
+			byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+		}
+		
+		FloatBuffer floatBuf = byteBuffer.asFloatBuffer();
+		float[] array = new float[floatBuf.remaining()];
+		floatBuf.get(array);		
+		
+		return array;
+	}
+
+	public static int[] toIntArray(byte[] data, boolean bigEndian) {
+		return toIntArray(data, 0, data.length, bigEndian);
+	}
+
+    public static int[] toIntArray(byte[] data, int offset, int len, boolean bigEndian) {
+		
+		ByteBuffer byteBuffer = ByteBuffer.wrap(data, offset, len);
+		
+		if (bigEndian) {
+			byteBuffer.order(ByteOrder.BIG_ENDIAN);
+		} else {
+			byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+		}
+		
+		IntBuffer intBuf = byteBuffer.asIntBuffer();
+		int[] array = new int[intBuf.remaining()];
+		intBuf.get(array);
+		
+		return array;
+	}
+	 
+	public static long[] toLongArray(byte[] data, boolean bigEndian) {
+		return toLongArray(data, 0, data.length, bigEndian);
+	}
+	
+	public static long[] toLongArray(byte[] data, int offset, int len, boolean bigEndian) {
+		
+		ByteBuffer byteBuffer = ByteBuffer.wrap(data, offset, len);
+		
+		if (bigEndian) {
+			byteBuffer.order(ByteOrder.BIG_ENDIAN);
+		} else {
+			byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+		}
+		
+		LongBuffer longBuf = byteBuffer.asLongBuffer();
+		long[] array = new long[longBuf.remaining()];
+		longBuf.get(array);
+		
+		return array;
+	}
+	
+	/**
+	 * Converts an input byte array to nBits data array using the smallest data type which
+	 * can hold the nBits data. Each data type contains only one data element.
+	 * 
+	 * @param nBits number of bits for the data element
+	 * @param input the input array for the data elements
+	 * @param stride scan line stride used to discard remaining bits 
+	 * @param bigEndian the packing order of the bits. True if bigEndian, otherwise false.
+	 * 
+	 * @return an array of the smallest data type which can hold the nBits data
+	 */
+	public static Object toNBits(int nBits, byte[] input, int stride, boolean bigEndian) {
+		int value = 0;
+   		int bits_remain = 0;
+   		int temp_byte = 0;
+   		
+   		byte[] byteOutput = null;
+   		short[] shortOutput = null;
+   		int[] intOutput = null;
+   		Object output = null;
+   		
+   		int outLen = (int)((input.length*8L + nBits - 1)/nBits);
+   		
+   		if(nBits <= 8) {
+   			byteOutput = new byte[outLen];
+   			output = byteOutput;
+   		} else if(nBits <= 16) {
+   			shortOutput = new short[outLen];
+   			output = shortOutput;
+   		} else if(nBits <= 32){
+   			intOutput = new int[outLen];
+   			output = intOutput;   			
+   		} else {
+   			throw new IllegalArgumentException("nBits exceeds limit - maximum 32");
+   		}
+   			
+   		int offset = 0;
+    	int index = 0;
+    	
+    	int strideCounter = 0;
+   		
+    	loop:
+   	  	while(true) {  			
+   	   		
+			if(!bigEndian)
+				value = (temp_byte >> (8-bits_remain));
+			else				
+				value = (temp_byte & MASK[bits_remain]); 
+				
+			while (nBits > bits_remain)
+			{
+				if(offset >= input.length) {
+					break loop;
+				}
+				
+				temp_byte = input[offset++]&0xff;
+				
+				if(bigEndian)
+					value = ((value<<8)|temp_byte);
+				else
+					value |= (temp_byte<<bits_remain);
+				
+				bits_remain += 8;
+			}
+			
+			bits_remain -= nBits;
+			
+			if(bigEndian)
+				value = (value>>(bits_remain));			
+	        
+			value &= MASK[nBits];
+			
+			if(++strideCounter%stride == 0) {
+				bits_remain = 0; // Discard the remaining bits			
+			}
+		
+			if(nBits <= 8) byteOutput[index++] = (byte)value;
+			else if(nBits <= 16) shortOutput[index++] = (short)value;
+			else intOutput[index++] = value;
+	  	}
+   		
+		return output;
+	}
+	
+	public static double[] toPrimitive(Double[] doubles) {
+		double[] dArray = new double[doubles.length];
+		int i = 0;
+		
+		for (double d : doubles) {
+			dArray[i++] = d;
+		}
+		
+		return dArray;
+	}
+	
+	public static float[] toPrimitive(Float[] floats) {
+		float[] fArray = new float[floats.length];
+		int i = 0;
+		
+		for (float f : floats) {
+			fArray[i++] = f;
+		}
+		
+		return fArray;
+	}
+	
+	public static int[] toPrimitive(Integer[] integers) {
+		int[] ints = new int[integers.length];
+		int i = 0;
+		
+		for (int n : integers) {
+			ints[i++] = n;
+		}
+		
+		return ints;
+	}
+	
+	public static long[] toPrimitive(Long[] longs) {
+		long[] lArray = new long[longs.length];
+		int i = 0;
+		
+		for (long l : longs) {
+			lArray[i++] = l;
+		}
+		
+		return lArray;
+	}
+	
+	public static short[] toPrimitive(Short[] shorts) {
+		short[] sArray = new short[shorts.length];
+		int i = 0;
+		
+		for (short s : shorts) {
+			sArray[i++] = s;
+		}
+		
+		return sArray;
+	}
+	
+	public static short[] toShortArray(byte[] data, boolean bigEndian) {
+		return toShortArray(data, 0, data.length, bigEndian);		
+	}
+	
+	public static short[] toShortArray(byte[] data, int offset, int len, boolean bigEndian) {
+		
+		ByteBuffer byteBuffer = ByteBuffer.wrap(data, offset, len);
+		
+		if (bigEndian) {
+			byteBuffer.order(ByteOrder.BIG_ENDIAN);
+		} else {
+			byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+		}
+		
+		ShortBuffer shortBuf = byteBuffer.asShortBuffer();
+		short[] array = new short[shortBuf.remaining()];
+		shortBuf.get(array);
+		
+		return array;
+	}
+   	
+   	private ArrayUtils(){} // Prevents instantiation
+}
diff --git a/src/pixy/util/Builder.java b/src/pixy/util/Builder.java
new file mode 100644
index 0000000..685db24
--- /dev/null
+++ b/src/pixy/util/Builder.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.util;
+
+// A builder interface for objects of type T
+public interface Builder<T> {
+    public T build();
+}
diff --git a/src/pixy/util/CollectionUtils.java b/src/pixy/util/CollectionUtils.java
new file mode 100644
index 0000000..2a2098f
--- /dev/null
+++ b/src/pixy/util/CollectionUtils.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.util;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.HashSet;
+import java.util.LinkedList;
+/**
+ * A collection utility class
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 10/12/2012
+ */
+public class CollectionUtils {
+	
+	public static <T, E> T getKeyByValue(Map<T, E> map, E value) {
+	     for (Map.Entry<T, E> entry : map.entrySet()) {
+	         if (value.equals(entry.getValue())) {
+	            return entry.getKey();
+	         }
+	     }
+	     return null;
+    }
+	
+	public static <T, E> Set<T> getKeysByValue(Map<T, E> map, E value) {
+	     Set<T> keys = new HashSet<T>();
+	     for (Map.Entry<T, E> entry : map.entrySet()) {
+	         if (value.equals(entry.getValue())) {
+	             keys.add(entry.getKey());
+	         }
+	     }
+	     return keys;
+	}
+
+	public static int[] integerListToIntArray(List<Integer> integers)
+	{
+	    int[] ret = new int[integers.size()];
+	    Iterator<Integer> iterator = integers.iterator();
+	    
+	    for (int i = 0; i < ret.length; i++)
+	    {
+	        ret[i] = iterator.next().intValue();
+	    }
+	    
+	    return ret;
+	}
+	
+	public static <T> LinkedList<T> reverseLinkedList(LinkedList<T> list){
+
+        if(list == null)
+            return null;
+
+        int size = list.size();
+        
+        for(int i = 0; i < size; i++){
+            list.add(i, list.removeLast());
+        }
+
+        return list;
+    }
+	
+	private CollectionUtils(){} // Prevents instantiation
+}
diff --git a/src/pixy/util/FileUtils.java b/src/pixy/util/FileUtils.java
new file mode 100644
index 0000000..4e618f5
--- /dev/null
+++ b/src/pixy/util/FileUtils.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.util;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class FileUtils {
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(FileUtils.class);
+		
+	public static void delete(String fileName) {
+		delete(fileName, "");
+	}
+	
+	public static void delete(String fileName, String fileExt) {
+		 File file = new File(fileName);
+		 delete(file, fileExt);
+	}
+	
+	public static String getNameWithoutExtension(File file) {
+		return file.getName().replaceFirst("[.][^.]+$", "");
+	}
+	
+	// Delete the file or if it's a directory, all files in the directory
+	public static void delete(File file, final String fileExt) {
+        if (file.exists()) {
+            //check if the file is a directory
+            if (file.isDirectory()) {            	
+            	File[] files = file.listFiles();
+            	for(File f:files){
+            		//call deletion of file individually
+            		delete(f, fileExt);
+            	}                
+            } else {
+            	String path = file.getAbsolutePath();
+            	if(fileExt != null && path.endsWith(fileExt)) {
+            		boolean result = file.delete();
+            		// test if delete of file is success or not
+            		if (result) {
+            			LOGGER.info("File {} deleted", file.getAbsolutePath());
+            		} else {
+            			LOGGER.info("File {} was not deleted, unknown reason", file.getAbsolutePath());
+            		}
+            	}
+            }
+        } else {
+        	LOGGER.info("File {} doesn't exist", file.getAbsolutePath());
+        }
+    }
+	
+	public static List<String> list(String dir) {
+		return list(dir, "");
+	}
+	
+	// List all files in a directory
+	public static List<String> list(String dir, String fileExt) {
+		 File file = new File(dir);
+		return list(file, fileExt);
+    }
+	
+	// List all files in a directory with the specified extension
+	public static List<String> list(File dir, final String fileExt) {
+		//
+		List<String> fileList = new ArrayList<String>();		
+            
+        //For all files and folders in directory
+        if(dir.isDirectory()) {
+        	//Get a list of all files and folders in directory
+        	File[] files = dir.listFiles();
+        	for(File f:files) {
+	        	//Recursively call file list function on the new directory
+	        	list(f, fileExt);
+	        }        	
+        }  else {
+        	//If not directory, print the file path and add it to return list
+        	String path = "";
+			try {
+				path = dir.getCanonicalPath();
+			} catch (IOException e) {
+				LOGGER.error("IOException", e);
+			}        	
+        	if(fileExt != null && path.endsWith(fileExt)) {
+        		fileList.add(path);        	   
+        		LOGGER.info("File: {}", path);
+        	}
+        }
+        
+        return fileList;
+    }
+	
+	// From: stackoverflow.com
+	public static File[] listFilesMatching(File root, String regex) {
+	    if(!root.isDirectory()) {
+	        throw new IllegalArgumentException(root+" is not a directory.");
+	    }
+	    final Pattern p = Pattern.compile(regex);
+	    return root.listFiles(new FileFilter(){
+	        public boolean accept(File file) {
+	            return p.matcher(file.getName()).matches();
+	        }
+	    });
+	}
+	
+	private FileUtils() {}
+}
diff --git a/src/pixy/util/IntHashtable.java b/src/pixy/util/IntHashtable.java
new file mode 100644
index 0000000..e08b999
--- /dev/null
+++ b/src/pixy/util/IntHashtable.java
@@ -0,0 +1,257 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.util;// Temporarily put in this package
+
+/**
+ * A hash table using primitive integer keys. 
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 01/06/2008
+ * 
+ * Based on
+ * QuadraticProbingHashTable.java
+ * <p>
+ * Probing table implementation of hash tables.
+ * Note that all "matching" is based on the equals method.
+ * 
+ * @author Mark Allen Weiss
+ */
+ public class IntHashtable<E>
+ {
+
+	    private static final int DEFAULT_TABLE_SIZE = 257;
+
+        /** The array of HashEntry. */
+        private HashEntry<E> [ ] array;   // The array of HashEntry
+        private int currentSize;       // The number of occupied cells
+      
+		/**
+         * Construct the hash table.
+         */
+        public IntHashtable( )
+        {
+            this( DEFAULT_TABLE_SIZE );
+        }
+
+        /**
+         * Construct the hash table.
+         * @param size the approximate initial size.
+         */
+        @SuppressWarnings("unchecked")
+		public IntHashtable( int size )
+        {
+            array = new HashEntry [size];
+            makeEmpty( );
+        }
+
+        /**
+         * Insert into the hash table. If the item is
+         * already present, do nothing.
+         * @param key the item to insert.
+         */
+        public void put( int key, E value )
+        {
+            // Insert key as active
+            int currentPos = locate( key );
+            if( isActive( currentPos ) )
+                return;
+
+            array[ currentPos ] = new HashEntry<E> ( key, value, true );
+
+            // Rehash
+            if( ++currentSize > array.length / 2 )
+                rehash( );
+        }
+
+        /**
+         * Expand the hash table.
+         */
+        @SuppressWarnings("unchecked")
+		private void rehash( )
+        {
+            HashEntry<E> [ ] oldArray = array;
+
+            // Create a new double-sized, empty table
+            array = new HashEntry [ nextPrime( 2 * oldArray.length ) ];
+            currentSize = 0;
+
+            // Copy table over
+            for( int i = 0; i < oldArray.length; i++ )
+                if( oldArray[i] != null && oldArray[i].isActive )
+                    put( oldArray[i].key, oldArray[i].value );
+
+            return;
+        }
+
+        /**
+         * Method that performs quadratic probing resolution.
+         * @param key the item to search for.
+         * @return the index of the item.
+         */
+        private int locate( int key )
+        {
+            int collisionNum = 0;
+
+			// And with the largest positive integer
+			int currentPos = (key & 0x7FFFFFFF) % array.length;
+	
+            while( array[ currentPos ] != null &&
+                    array[ currentPos ].key != key )
+            {
+                currentPos += 2 * ++collisionNum - 1;  // Compute ith probe
+                if( currentPos >= array.length )       // Implement the mod
+                  currentPos -= array.length;
+            }
+            return currentPos;
+        }
+
+        /**
+         * Remove from the hash table.
+         * @param key the item to remove.
+         */
+        public void remove( int key )
+        {
+            int currentPos = locate( key );
+            if( isActive( currentPos ) )
+			{
+                array[ currentPos ].isActive = false;
+				currentSize--;
+			}
+        }
+        
+		/**
+         * Search for an item in the hash table.
+         * @param key the item to search for.
+         * @return true if a matching item found.
+         */
+        public boolean contains(int key)
+	    {
+	        return isActive( locate( key ) );
+		}
+
+        /**
+         * Find an item in the hash table.
+         * @param key the item to search for.
+         * @return the value of the matching item.
+         */
+        public E get( int key )
+        {
+            int currentPos = locate( key );
+	        return isActive( currentPos ) ? array[ currentPos ].value : null;
+        }
+
+        /**
+         * Return true if currentPos exists and is active.
+         * @param currentPos the result of a call to findPos.
+         * @return true if currentPos is active.
+         */
+        private boolean isActive( int currentPos )
+        {
+            return array[ currentPos ] != null && array[ currentPos ].isActive;
+        }
+
+        /**
+         * Make the hash table logically empty.
+         */
+        public void makeEmpty( )
+        {
+            currentSize = 0;
+            for( int i = 0; i < array.length; i++ )
+                array[ i ] = null;
+        }
+        /**
+         * Internal method to find a prime number at least as large as n.
+         * @param n the starting number (must be positive).
+         * @return a prime number larger than or equal to n.
+         */
+        private static int nextPrime( int n )
+        {
+            if( n % 2 == 0 )
+                n++;
+
+            for( ; !isPrime( n ); n += 2 )
+                ;
+
+            return n;
+        }
+
+        /**
+         * Internal method to test if a number is prime.
+         * Not an efficient algorithm.
+         * @param n the number to test.
+         * @return the result of the test.
+         */
+        private static boolean isPrime( int n )
+        {
+            if( n == 2 || n == 3 )
+                return true;
+
+            if( n == 1 || n % 2 == 0 )
+                return false;
+
+            for( int i = 3; i * i <= n; i += 2 )
+                if( n % i == 0 )
+                    return false;
+
+            return true;
+        }
+        // The basic entry stored in ProbingHashTable
+        private static class HashEntry<V>
+        {
+           int key;         // the key
+	       V value;       // the value
+           boolean  isActive;  // false if deleted
+  
+  	       @SuppressWarnings("unused")
+  	       HashEntry( int k, V val )
+           {
+               this( k, val, true );
+           }
+
+           HashEntry( int k, V val, boolean i )
+           {
+               key = k;
+		       value = val;
+               isActive  = i;
+           }
+        }
+        // Simple main
+        public static void main( String [ ] args )
+        {
+            IntHashtable<Integer> H = new IntHashtable<Integer> ( );
+
+            final int NUMS = 4000;
+            final int GAP  =   37;
+
+            System.out.println( "Checking... (no more output means success)" );
+
+
+            for( int i = GAP; i != 0; i = ( i + GAP ) % NUMS )
+                H.put( i, new Integer(i) );
+            for( int i = 1; i < NUMS; i+= 2 )
+                H.remove( i );
+
+            for( int i = 2; i < NUMS; i+=2 )
+                if( H.get(i) != i )
+                    System.out.println( "Find fails " + i );
+
+            for( int i = 1; i < NUMS; i+=2 )
+            {
+                if( H.get(i) != null )
+                    System.out.println( "OOPS!!! " +  i  );
+            }
+        }
+ }
diff --git a/src/pixy/util/LangUtils.java b/src/pixy/util/LangUtils.java
new file mode 100644
index 0000000..aa479c0
--- /dev/null
+++ b/src/pixy/util/LangUtils.java
@@ -0,0 +1,263 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.util;
+
+import java.security.ProtectionDomain;
+import java.security.CodeSource;
+import java.util.Arrays;
+import java.util.regex.Pattern;
+import java.io.PrintStream;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.Method;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.net.URI;
+import java.net.URL;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+/**
+ * A common language utility class
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 09/19/2012
+ */
+public class LangUtils {
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(LangUtils.class);
+	
+	private LangUtils(){} // Prevents instantiation
+	
+	// TODO: need to rewrite this method (may have to create and return a new class Rational)
+	public static long[] doubleToRational(double number) {
+		// Code below doesn't work for 0 and NaN - just check before
+		if((number == 0.0d) || Double.isNaN(number)) {
+			throw new IllegalArgumentException(number + " cannot be represented as a rational number");
+		}
+		
+		long bits = Double.doubleToLongBits(number);
+
+		long sign = bits >>> 63;
+		long exponent = ((bits >>> 52) ^ (sign << 11)) - 1023;
+		long fraction = bits << 12; // bits are "reversed" but that's not a problem
+
+		long a = 1L;
+		long b = 1L;
+
+		for (int i = 63; i >= 12; i--) {
+		    a = a * 2 + ((fraction >>> i) & 1);
+		    b *= 2;
+		}
+
+		if (exponent > 0)
+		    a *= 1 << exponent;
+		else
+		    b *= 1 << -exponent;
+
+		if (sign == 1)
+		    a *= -1;
+
+		return new long[]{a, b};
+	}
+	
+	// From Effective Java 2nd Edition. 
+	// Use of asSubclass to safely cast to a bounded type token
+	public static Annotation getAnnotation(AnnotatedElement element, String annotationTypeName) {
+	     Class<?> annotationType = null; // Unbounded type token
+	
+	     try {
+	            annotationType = Class.forName(annotationTypeName);
+	     } catch (Exception ex) {
+	            throw new IllegalArgumentException(ex);
+	     }
+	
+	     return element.getAnnotation(annotationType.asSubclass(Annotation.class));
+	}
+	
+	// Creates a friendly string representation of the class
+	public static String getClassName(Class<?> c) {
+	    String name = c.getName().replace('$','.');
+	    
+	    if (c.isArray()) {
+	    	switch (name.charAt(1)) {
+			    case 'B':
+					name = "byte";
+					break;
+				case 'C':
+					name = "char";
+					break;
+				case 'D':
+					name = "double";
+					break;
+			    case 'F':
+					name = "float";
+					break;
+			    case 'I':
+					name = "int";
+					break;
+			    case 'J':
+					name = "long";
+					break;
+			    case 'L':
+					name = name.substring(2, name.length() - 1);
+					break;
+			    case 'S':
+					name = "short";
+					break;
+			    case 'Z':
+					name = "boolean";
+					break;
+	    	}
+			name = name + "[]";
+	    }
+	    
+	    return name;
+	}
+	
+	/**
+	 * @param m Method we want to probe generic type arguments.
+	 * @param i the i'th parameter of the method.
+	 * @return an array of parameterized Types for the i'th argument or an empty array. 
+	 */
+	public static Type[] getGenericTypeArguments(Method m, int i) {		 
+		 try {
+			 Type t = m.getGenericParameterTypes()[i];
+		 
+			 if(t instanceof ParameterizedType) {
+				 ParameterizedType pt = (ParameterizedType) t;
+				 return pt.getActualTypeArguments();
+			 }
+		 } catch(Exception e) {
+			 LOGGER.error("Error probing generic type arguments!", e);
+		 }
+		 
+		 return new Type[]{};
+	}
+	
+	public static void log(String message, PrintStream out) {
+		StackTraceElement se = Thread.currentThread().getStackTrace()[2];
+		out.println("; " + message + " - [" + se.getClassName() + "." + se.getMethodName() +"(): line " + se.getLineNumber() + "]");		
+	}
+	
+	/** Java language specific classes return null cSource */
+	public static URL getLoadedClassLocation(Class<?> cls) {
+		ProtectionDomain pDomain = cls.getProtectionDomain();
+		CodeSource cSource = pDomain.getCodeSource();
+		URL loc = (cSource==null)?null:cSource.getLocation(); 
+		
+		return loc; 
+	}
+	
+	/**
+	 * @param className A fully qualified class name with package information
+	 * @return The location where the class has been loaded by the Java Virtual
+	 * Machine or null.
+	 */
+	public static URL getLoadedClassLocation(String className) {
+		Class<?> cls = null;
+		
+		try	{
+			cls = Class.forName(className);
+		} catch (ClassNotFoundException ex)	{
+			return null;			
+		}
+		
+		return getLoadedClassLocation(cls);
+	}
+	
+	public static URL getLoadedClassURL(String className) {
+		Class<?> cls = null;
+		
+		try	{
+			cls = Class.forName(className);
+		} catch (ClassNotFoundException ex) { 
+			return null;			
+		}
+		
+		ClassLoader classLoader = cls.getClassLoader();
+
+		URL url = classLoader.getResource(className.replaceAll(Pattern.quote("."), "/") + ".class");
+		
+		return url;
+	}
+	
+	// A convenience way to call main of other classes.
+	// Based on something I am not sure where I got it.
+	public static void invokeMain(String... args) {
+		try {
+		    Class<?> c = Class.forName(args[0]);
+			Class<String[]> argTypes = String[].class;
+			Method main = c.getDeclaredMethod("main", argTypes);
+	  	    Object mainArgs = Arrays.copyOfRange(args, 1, args.length);
+		    LOGGER.info("invoking {}.main()\n", c.getName());
+		    main.invoke(null, mainArgs);
+		} catch (Exception ex) {
+			ex.printStackTrace();
+		}
+	}
+	
+	/**
+	 * Converts long value to int hash code.
+	 * 
+	 * @param value long value
+	 * @return int hash code for the long
+	 */
+	public static int longToIntHashCode(long value) {
+		return Long.valueOf(value).hashCode();
+	}
+		
+	// From stackoverflow.com
+	public static URI relativize(URI base, URI child) {
+		 // Normalize paths to remove . and .. segments
+		 base = base.normalize();
+		 child = child.normalize();
+
+		 // Split paths into segments
+		 String[] bParts = base.getPath().split("/");
+		 String[] cParts = child.getPath().split("/");
+
+		 // Discard trailing segment of base path
+		 if (bParts.length > 0 && !base.getPath().endsWith("/")) {
+		     System.arraycopy(bParts, 0, bParts, 0, bParts.length - 1);
+			 // JDK1.6+ 
+			 //bParts = java.util.Arrays.copyOf(bParts, bParts.length - 1);
+		 }
+
+		 // Remove common prefix segments
+		 int i = 0;
+		  
+		 while (i < bParts.length && i < cParts.length && bParts[i].equals(cParts[i])) {
+			i++;
+		 }
+
+		 // Construct the relative path
+		 StringBuilder sb = new StringBuilder();
+		  
+		 for (int j = 0; j < (bParts.length - i); j++) {
+			sb.append("../");
+		 }
+		  
+		 for (int j = i; j < cParts.length; j++) {
+			if (j != i) {
+			  sb.append("/");
+			}
+			sb.append(cParts[j]);
+		 }
+		  
+		 return URI.create(sb.toString());
+	}	
+}
diff --git a/src/pixy/util/MetadataUtils.java b/src/pixy/util/MetadataUtils.java
new file mode 100644
index 0000000..1d77638
--- /dev/null
+++ b/src/pixy/util/MetadataUtils.java
@@ -0,0 +1,206 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ *
+ * Change History - most recent changes go on top of previous changes
+ *
+ * MetadataUtils.java
+ *
+ * Who   Date       Description
+ * ====  =========  ==============================================================
+ * WY    13Mar2015  Initial creation
+ */
+
+package pixy.util;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import pixy.io.PeekHeadInputStream;
+import pixy.io.RandomAccessInputStream;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import pixy.meta.adobe.ImageResourceID;
+import pixy.meta.adobe._8BIM;
+import pixy.image.ImageType;
+import pixy.io.IOUtils;
+
+/** 
+ * This utility class contains static methods 
+ * to help with image manipulation and IO. 
+ * <p>
+ * 
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.1.2 04/02/2012
+ */
+public class MetadataUtils {
+	// Image magic number constants
+	private static byte[] BM = {0x42, 0x4d}; // BM
+	private static byte[] GIF = {0x47, 0x49, 0x46, 0x38}; // GIF8
+	private static byte[] PNG = {(byte)0x89, 0x50, 0x4e, 0x47}; //.PNG
+	private static byte[] TIFF_II = {0x49, 0x49, 0x2a, 0x00}; // II*.
+	private static byte[] TIFF_MM = {0x4d, 0x4d, 0x00, 0x2a}; //MM.*
+	private static byte[] JPG = {(byte)0xff, (byte)0xd8, (byte)0xff};
+	private static byte[] PCX = {0x0a};
+	private static byte[] JPG2000 = {0x00, 0x00, 0x00, 0x0C};
+	
+	public static final int IMAGE_MAGIC_NUMBER_LEN = 4; 
+	
+	// Obtain a logger instance
+	private static final Logger LOGGER = LoggerFactory.getLogger(MetadataUtils.class);
+
+	public static ImageType guessImageType(PeekHeadInputStream is) throws IOException {
+		// Read the first ImageIO.IMAGE_MAGIC_NUMBER_LEN bytes
+		byte[] magicNumber = is.peek(IMAGE_MAGIC_NUMBER_LEN);
+		ImageType imageType = guessImageType(magicNumber);
+		
+		return imageType;
+	}
+	
+	public static ImageType guessImageType(byte[] magicNumber) {
+		ImageType imageType = ImageType.UNKNOWN;
+		// Check image type
+		if(Arrays.equals(magicNumber, TIFF_II) || Arrays.equals(magicNumber, TIFF_MM))
+			imageType = ImageType.TIFF;
+		else if(Arrays.equals(magicNumber, PNG))
+			imageType = ImageType.PNG;
+		else if(Arrays.equals(magicNumber, GIF))
+			imageType = ImageType.GIF;
+		else if(magicNumber[0] == JPG[0] && magicNumber[1] == JPG[1] && magicNumber[2] == JPG[2])
+			imageType = ImageType.JPG;
+		else if(magicNumber[0] == BM[0] && magicNumber[1] == BM[1])
+			imageType = ImageType.BMP;
+		else if(magicNumber[0] == PCX[0])
+			imageType = ImageType.PCX;
+		else if(Arrays.equals(magicNumber, JPG2000)) {
+			imageType = ImageType.JPG2000;
+		} else if(magicNumber[1] == 0 || magicNumber[1] == 1) {
+			switch(magicNumber[2]) {
+				case 0:
+				case 1:
+				case 2:
+				case 3:
+				case 9:
+				case 10:
+				case 11:
+				case 32:
+				case 33:
+					imageType = ImageType.TGA;					
+			}
+		} else {
+			LOGGER.error("Unknown format!");		
+		}
+		
+		return imageType;
+	}
+	
+	public static Bitmap createThumbnail(InputStream is) throws IOException {
+		Bitmap original = null;
+		if(is instanceof RandomAccessInputStream) {
+			RandomAccessInputStream rin = (RandomAccessInputStream)is;
+			long streamPointer = rin.getStreamPointer();
+			rin.seek(streamPointer);
+			original = BitmapFactory.decodeStream(rin);
+			// Reset the stream pointer
+			rin.seek(streamPointer);
+		} else {
+			original = BitmapFactory.decodeStream(is);
+		}		
+		int imageWidth = original.getWidth();
+		int imageHeight = original.getHeight();
+		int thumbnailWidth = 160;
+		int thumbnailHeight = 120;
+		if(imageWidth < imageHeight) { 
+			// Swap thumbnail width and height to keep a relative aspect ratio
+			int temp = thumbnailWidth;
+			thumbnailWidth = thumbnailHeight;
+			thumbnailHeight = temp;
+		}			
+		if(imageWidth < thumbnailWidth) thumbnailWidth = imageWidth;			
+		if(imageHeight < thumbnailHeight) thumbnailHeight = imageHeight;
+		
+		Bitmap thumbnail = Bitmap.createScaledBitmap(original, thumbnailWidth, thumbnailHeight, false);
+				
+		return thumbnail;
+	}
+	
+	/**
+	 * Wraps a BufferedImage inside a Photoshop _8BIM
+	 * @param thumbnail input thumbnail image
+	 * @return a Photoshop _8BMI
+	 * @throws IOException
+	 */
+	public static _8BIM createThumbnail8BIM(Bitmap thumbnail) throws IOException {
+		// Create memory buffer to write data
+		ByteArrayOutputStream bout = new ByteArrayOutputStream();
+		// Compress the thumbnail
+		try {
+			thumbnail.compress(Bitmap.CompressFormat.JPEG, 100, bout);
+		} catch (Exception e) {
+			e.printStackTrace();
+		}
+		byte[] data = bout.toByteArray();
+		bout.reset();
+		// Write thumbnail format
+		IOUtils.writeIntMM(bout, 1); // 1 = kJpegRGB. We are going to write JPEG format thumbnail
+		// Write thumbnail dimension
+		int width = thumbnail.getWidth();
+		int height = thumbnail.getHeight();
+		IOUtils.writeIntMM(bout, width);
+		IOUtils.writeIntMM(bout, height);
+		// Padded row bytes = (width * bits per pixel + 31) / 32 * 4.
+		int bitsPerPixel = 24;
+		int planes = 1;
+		int widthBytes = (width*bitsPerPixel + 31)/32*4;
+		IOUtils.writeIntMM(bout, widthBytes);
+		// Total size = widthbytes * height * planes
+		IOUtils.writeIntMM(bout, widthBytes*height*planes);
+		// Size after compression. Used for consistency check.
+		IOUtils.writeIntMM(bout, data.length);
+		IOUtils.writeShortMM(bout, bitsPerPixel);
+		IOUtils.writeShortMM(bout, planes);
+		bout.write(data);
+		// Create 8BIM
+		_8BIM bim = new _8BIM(ImageResourceID.THUMBNAIL_RESOURCE_PS5, "thumbnail", bout.toByteArray());
+	
+		return bim;
+	}
+	
+	public static int[] toARGB(byte[] rgb) {
+		int[] argb = new int[rgb.length / 3];
+		int index = 0;
+		for(int i = 0; i < argb.length; i++) {
+			argb[i] = 0xFF << 24 | (rgb[index++] & 0xFF) << 16 | (rgb[index++] & 0xFF) << 8 | (rgb[index++] & 0xFF);
+		}
+		
+		return argb;
+	}
+	
+	public static int[] bgr2ARGB(byte[] bgr) {
+		int[] argb = new int[bgr.length / 3];
+		int index = 0;
+		for(int i = 0; i < argb.length; i++) {
+			argb[i] = 0xFF << 24 | (bgr[index++] & 0xFF) |  (bgr[index++] & 0xFF) << 8 | (bgr[index++] & 0xFF) << 16;
+		}
+		
+		return argb;
+	}
+	
+	// Prevent from instantiation
+	private MetadataUtils(){}
+}
diff --git a/src/pixy/util/Reader.java b/src/pixy/util/Reader.java
new file mode 100644
index 0000000..bc3ee38
--- /dev/null
+++ b/src/pixy/util/Reader.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.util;
+
+import java.io.IOException;
+
+public interface Reader {	
+	public void read() throws IOException;
+}
diff --git a/src/pixy/util/zip/CRC32.java b/src/pixy/util/zip/CRC32.java
new file mode 100644
index 0000000..4501239
--- /dev/null
+++ b/src/pixy/util/zip/CRC32.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.util.zip;
+
+/** 
+ * Table based CRC32 implementation.
+ *
+ * @author Wen Yu, yuwen_66@yahoo.com
+ * @version 1.0 11/01/2013
+ */
+public class CRC32 implements Checksum {
+	//
+	private volatile int crc32;
+	
+	private static final int crc32_table[] = {
+		0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f,
+		0xe963a535, 0x9e6495a3,	0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988,
+		0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2,
+		0xf3b97148, 0x84be41de,	0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7,
+		0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec,	0x14015c4f, 0x63066cd9,
+		0xfa0f3d63, 0x8d080df5,	0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172,
+		0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b,	0x35b5a8fa, 0x42b2986c,
+		0xdbbbc9d6, 0xacbcf940,	0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59,
+		0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423,
+		0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924,
+		0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d,	0x76dc4190, 0x01db7106,
+		0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433,
+		0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d,
+		0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e,
+		0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950,
+		0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65,
+		0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7,
+		0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0,
+		0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa,
+		0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f,
+		0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81,
+		0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a,
+		0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84,
+		0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1,
+		0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb,
+		0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc,
+		0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e,
+		0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
+		0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55,
+		0x316e8eef, 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236,
+		0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28,
+		0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d,
+		0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f,
+		0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38,
+		0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242,
+		0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777,
+		0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69,
+		0x616bffd3, 0x166ccf45, 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2,
+		0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc,
+		0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9,
+		0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693,
+		0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94,
+		0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d
+	};
+	
+	public long getValue() {
+		return crc32 & 0XFFFFFFFFL;
+	}
+	
+	public void reset() {
+		crc32 = 0;
+	}
+	
+	public void update (int i)
+	{
+		int temp = ~crc32;
+	    temp = crc32_table[(temp ^ i) & 0xff] ^ (temp >>> 8);
+	    crc32 = ~temp;
+	}
+	
+	public void update(byte[] buff) {
+		update(buff, 0, buff.length);
+	}
+	
+	public void update(byte[] buff, int offset, int size) {
+	    int	temp = ~crc32;
+
+		while (size-->0)
+			temp = crc32_table[(temp^buff[offset++]) & 0xFF] ^ (temp >>> 8);
+		crc32 = ~temp;
+	}
+}
diff --git a/src/pixy/util/zip/Checksum.java b/src/pixy/util/zip/Checksum.java
new file mode 100644
index 0000000..a6fcddb
--- /dev/null
+++ b/src/pixy/util/zip/Checksum.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2014-2021 by Wen Yu
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the Eclipse
+ * Public License, v. 2.0 are satisfied: GNU General Public License, version 2
+ * or any later version.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
+ */
+
+package pixy.util.zip;
+
+/**
+ * Interface to be implemented by CRC32 and Adler32 etc.
+ */
+public interface Checksum
+{  
+  public long getValue();
+  
+  public void update(int b);
+ 
+  public void update(byte[] b, int offset, int length);
+ 
+  public void reset();
+}