TIFFUtilities.java

/*
 * Copyright (c) 2013, Oliver Schmidtmer, Harald Kuhr
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * * Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 *
 * * Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 *
 * * Neither the name of the copyright holder nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 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
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package com.twelvemonkeys.contrib.tiff;

import com.twelvemonkeys.image.AffineTransformOp;
import com.twelvemonkeys.imageio.metadata.AbstractDirectory;
import com.twelvemonkeys.imageio.metadata.CompoundDirectory;
import com.twelvemonkeys.imageio.metadata.Directory;
import com.twelvemonkeys.imageio.metadata.Entry;
import com.twelvemonkeys.imageio.metadata.jpeg.JPEG;
import com.twelvemonkeys.imageio.metadata.tiff.*;
import com.twelvemonkeys.lang.Validate;

import javax.imageio.IIOException;
import javax.imageio.ImageIO;
import javax.imageio.stream.ImageInputStream;
import javax.imageio.stream.ImageOutputStream;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.io.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;

/**
 * TIFFUtilities for manipulation TIFF Images and Metadata
 *
 * @author <a href="mailto:mail@schmidor.de">Oliver Schmidtmer</a>
 * @author last modified by $Author$
 * @version $Id$
 */
public final class TIFFUtilities {
    private TIFFUtilities() {
    }

    /**
     * Merges all pages from the input TIFF files into one TIFF file at the
     * output location.
     *
     * @param inputFiles
     * @param outputFile
     * @throws IOException
     */
    public static void merge(List<File> inputFiles, File outputFile) throws IOException {
        ImageOutputStream output = null;
        try {
            output = ImageIO.createImageOutputStream(outputFile);

            for (File file : inputFiles) {
                ImageInputStream input = null;
                try {
                    input = ImageIO.createImageInputStream(file);
                    List<TIFFPage> pages = getPages(input);
                    writePages(output, pages);
                }
                finally {
                    if (input != null) {
                        input.close();
                    }
                }
            }
        }
        finally {
            if (output != null) {
                output.flush();
                output.close();
            }
        }
    }

    /**
     * Splits all pages from the input TIFF file to one file per page in the
     * output directory.
     *
     * @param inputFile
     * @param outputDirectory
     * @return generated files
     * @throws IOException
     */
    public static List<File> split(File inputFile, File outputDirectory) throws IOException {
        ImageInputStream input = null;
        List<File> outputFiles = new ArrayList<>();
        try {
            input = ImageIO.createImageInputStream(inputFile);
            List<TIFFPage> pages = getPages(input);
            int pageNo = 1;
            for (TIFFPage tiffPage : pages) {
                ArrayList<TIFFPage> outputPages = new ArrayList<TIFFPage>(1);
                ImageOutputStream outputStream = null;
                try {
                    File outputFile = new File(outputDirectory, String.format("%04d", pageNo) + ".tif");
                    outputStream = ImageIO.createImageOutputStream(outputFile);
                    outputPages.clear();
                    outputPages.add(tiffPage);
                    writePages(outputStream, outputPages);
                    outputFiles.add(outputFile);
                }
                finally {
                    if (outputStream != null) {
                        outputStream.flush();
                        outputStream.close();
                    }
                }
                ++pageNo;
            }
        }
        finally {
            if (input != null) {
                input.close();
            }
        }
        return outputFiles;
    }

    /**
     * Rotates all pages of a TIFF file by changing TIFF.TAG_ORIENTATION.
     * <p>
     * NOTICE: TIFF.TAG_ORIENTATION is an advice how the image is meant do be
     * displayed. Other metadata, such as width and height, relate to the image
     * as how it is stored. The ImageIO TIFF plugin does not handle orientation.
     * Use {@link TIFFUtilities#applyOrientation(BufferedImage, int)} for
     * applying TIFF.TAG_ORIENTATION.
     * </p>
     *
     * @param imageInput
     * @param imageOutput
     * @param degree      Rotation amount, supports 90���, 180��� and 270���.
     * @throws IOException
     */
    public static void rotatePages(ImageInputStream imageInput, ImageOutputStream imageOutput, int degree)
            throws IOException {
        rotatePage(imageInput, imageOutput, degree, -1);
    }

    /**
     * Rotates a page of a TIFF file by changing TIFF.TAG_ORIENTATION.
     * <p>
     * NOTICE: TIFF.TAG_ORIENTATION is an advice how the image is meant do be
     * displayed. Other metadata, such as width and height, relate to the image
     * as how it is stored. The ImageIO TIFF plugin does not handle orientation.
     * Use {@link TIFFUtilities#applyOrientation(BufferedImage, int)} for
     * applying TIFF.TAG_ORIENTATION.
     * </p>
     *
     * @param imageInput
     * @param imageOutput
     * @param degree      Rotation amount, supports 90���, 180��� and 270���.
     * @param pageIndex   page which should be rotated or -1 for all pages.
     * @throws IOException
     */
    public static void rotatePage(ImageInputStream imageInput, ImageOutputStream imageOutput, int degree, int pageIndex)
            throws IOException {
        ImageInputStream input = null;
        try {
            List<TIFFPage> pages = getPages(imageInput);
            if (pageIndex != -1) {
                pages.get(pageIndex).rotate(degree);
            }
            else {
                for (TIFFPage tiffPage : pages) {
                    tiffPage.rotate(degree);
                }
            }
            writePages(imageOutput, pages);
        }
        finally {
            if (input != null) {
                input.close();
            }
        }
    }

    public static List<TIFFPage> getPages(ImageInputStream imageInput) throws IOException {
        CompoundDirectory IFDs = (CompoundDirectory) new TIFFReader().read(imageInput);

        final int pageCount = IFDs.directoryCount();
        List<TIFFPage> pages = new ArrayList<>(pageCount);
        for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) {
            pages.add(new TIFFPage(IFDs.getDirectory(pageIndex), imageInput));
        }

        return pages;
    }

    public static void writePages(ImageOutputStream imageOutput, List<TIFFPage> pages) throws IOException {
        TIFFWriter exif = new TIFFWriter();
        long nextPagePos = imageOutput.getStreamPosition();
        if (nextPagePos == 0) {
            exif.writeTIFFHeader(imageOutput);
            nextPagePos = imageOutput.getStreamPosition();
            imageOutput.writeInt(0);
        }
        else {
            // already has pages, so remember place of EOF to replace with
            // IFD offset
            nextPagePos -= 4;
        }

        for (TIFFPage tiffPage : pages) {
            long ifdOffset = tiffPage.write(imageOutput, exif);

            long tmp = imageOutput.getStreamPosition();
            imageOutput.seek(nextPagePos);
            imageOutput.writeInt((int) ifdOffset);
            imageOutput.seek(tmp);
            nextPagePos = tmp;
            imageOutput.writeInt(0);
        }
    }

    public static BufferedImage applyOrientation(BufferedImage input, int orientation) {
        boolean flipExtends = false;
        int w = input.getWidth();
        int h = input.getHeight();
        double cW = w / 2.0;
        double cH = h / 2.0;

        AffineTransform orientationTransform = new AffineTransform();
        switch (orientation) {
            case TIFFBaseline.ORIENTATION_TOPLEFT:
                // normal
                return input;
            case TIFFExtension.ORIENTATION_TOPRIGHT:
                // flipped vertically
                orientationTransform.translate(cW, cH);
                orientationTransform.scale(-1, 1);
                orientationTransform.translate(-cW, -cH);
                break;
            case TIFFExtension.ORIENTATION_BOTRIGHT:
                // rotated 180
                orientationTransform.quadrantRotate(2, cW, cH);
                break;
            case TIFFExtension.ORIENTATION_BOTLEFT:
                // flipped horizontally
                orientationTransform.translate(cW, cH);
                orientationTransform.scale(1, -1);
                orientationTransform.translate(-cW, -cH);
                break;
            case TIFFExtension.ORIENTATION_LEFTTOP:
                orientationTransform.translate(cW, cH);
                orientationTransform.scale(-1, 1);
                orientationTransform.quadrantRotate(1);
                orientationTransform.translate(-cW, -cH);
                flipExtends = true;
                break;
            case TIFFExtension.ORIENTATION_RIGHTTOP:
                // rotated 90
                orientationTransform.quadrantRotate(1, cW, cH);
                flipExtends = true;
                break;
            case TIFFExtension.ORIENTATION_RIGHTBOT:
                orientationTransform.translate(cW, cH);
                orientationTransform.scale(1, -1);
                orientationTransform.quadrantRotate(1);
                orientationTransform.translate(-cW, -cH);
                flipExtends = true;
                break;
            case TIFFExtension.ORIENTATION_LEFTBOT:
                // rotated 270
                orientationTransform.quadrantRotate(3, cW, cH);
                flipExtends = true;
                break;
        }

        int newW, newH;
        if (flipExtends) {
            newW = h;
            newH = w;
        }
        else {
            newW = w;
            newH = h;
        }

        AffineTransform transform = AffineTransform.getTranslateInstance((newW - w) / 2.0, (newH - h) / 2.0);
        transform.concatenate(orientationTransform);
        AffineTransformOp transformOp = new AffineTransformOp(transform, null);
        return transformOp.filter(input, null);
    }

    public static class TIFFPage {
        private Directory IFD;
        private ImageInputStream stream;

        private TIFFPage(Directory IFD, ImageInputStream stream) {
            this.IFD = IFD;
            this.stream = stream;
        }

        private long write(ImageOutputStream outputStream, TIFFWriter tiffWriter) throws IOException {
            List<Entry> newIFD = writeDirectoryData(IFD, outputStream);
            return tiffWriter.writeIFD(newIFD, outputStream);
        }

        private List<Entry> writeDirectoryData(Directory IFD, ImageOutputStream outputStream) throws IOException {
            ArrayList<Entry> newIFD = new ArrayList<Entry>();
            Iterator<Entry> it = IFD.iterator();
            while (it.hasNext()) {
                Entry e = it.next();
                if (e.getValue() instanceof Directory) {
                    List<Entry> subIFD = writeDirectoryData((Directory) e.getValue(), outputStream);
                    new TIFFEntry((Integer) e.getIdentifier(), TIFF.TYPE_IFD, new AbstractDirectory(subIFD) {
                    });
                }

                newIFD.add(e);
            }

            long[] offsets = new long[0];
            long[] byteCounts = new long[0];
            int[] newOffsets = new int[0];
            boolean useTiles = false;
            Entry stripOffsetsEntry = IFD.getEntryById(TIFF.TAG_STRIP_OFFSETS);
            Entry stripByteCountsEntry = IFD.getEntryById(TIFF.TAG_STRIP_BYTE_COUNTS);
            if (stripOffsetsEntry != null && stripByteCountsEntry != null) {
                offsets = getValueAsLongArray(stripOffsetsEntry);
                byteCounts = getValueAsLongArray(stripByteCountsEntry);
            }
            else {
                stripOffsetsEntry = IFD.getEntryById(TIFF.TAG_TILE_OFFSETS);
                stripByteCountsEntry = IFD.getEntryById(TIFF.TAG_TILE_BYTE_COUNTS);
                if (stripOffsetsEntry != null && stripByteCountsEntry != null) {
                    offsets = getValueAsLongArray(stripOffsetsEntry);
                    byteCounts = getValueAsLongArray(stripByteCountsEntry);
                    useTiles = true;
                }
            }

            int compression = -1;
            Entry compressionEntry = IFD.getEntryById(TIFF.TAG_COMPRESSION);
            if (compressionEntry != null && compressionEntry.getValue() instanceof Number) {
                compression = ((Number) compressionEntry.getValue()).shortValue();
            }

            boolean rearrangedByteStrips = false;
            Entry oldJpegData = IFD.getEntryById(TIFF.TAG_JPEG_INTERCHANGE_FORMAT);
            Entry oldJpegDataLength = IFD.getEntryById(TIFF.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH);
            long[] jpegByteCounts = null;
            long[] jpegOffsets = null;
            if (oldJpegData != null && oldJpegData.valueCount() > 0) {
                // convert JPEGInterchangeFormat to new-style-JPEG
                jpegByteCounts = new long[0];
                jpegOffsets = getValueAsLongArray(oldJpegData);
                if (oldJpegDataLength != null && oldJpegDataLength.valueCount() > 0) {
                    jpegByteCounts = getValueAsLongArray(oldJpegDataLength);
                }

                if (offsets.length == 1 && offsets[0] == jpegOffsets[0]) {
                    // JPEGInterchangeFormat identical to stripdata
                    newIFD.remove(oldJpegData);
                    newIFD.remove(oldJpegDataLength);
                }
                else if (offsets.length == 1 && oldJpegDataLength != null && offsets[0] == (jpegOffsets[0] + jpegByteCounts[0])) {
                    // prepend JPEGInterchangeFormat to stripdata
                    newOffsets = writeData(jpegOffsets, jpegByteCounts, outputStream);
                    writeData(offsets, byteCounts, outputStream);

                    newIFD.remove(stripOffsetsEntry);
                    newIFD.add(new TIFFEntry(useTiles ? TIFF.TAG_TILE_OFFSETS : TIFF.TAG_STRIP_OFFSETS, newOffsets));
                    newIFD.remove(stripByteCountsEntry);
                    newIFD.add(new TIFFEntry(useTiles ? TIFF.TAG_TILE_BYTE_COUNTS : TIFF.TAG_STRIP_BYTE_COUNTS, new int[]{(int) (jpegByteCounts[0] + byteCounts[0])}));

                    newIFD.remove(oldJpegData);
                    newIFD.remove(oldJpegDataLength);
                    rearrangedByteStrips = true;
                }
                else if (offsets.length == 1 && oldJpegDataLength != null && (jpegOffsets[0] < offsets[0]) && (jpegOffsets[0] + jpegByteCounts[0]) > (offsets[0] + byteCounts[0])) {

                    // ByteStrip contains only a part of JPEGInterchangeFormat
                    newOffsets = writeData(jpegOffsets, jpegByteCounts, outputStream);

                    newIFD.remove(stripOffsetsEntry);
                    newIFD.add(new TIFFEntry(useTiles ? TIFF.TAG_TILE_OFFSETS : TIFF.TAG_STRIP_OFFSETS, newOffsets));
                    newIFD.remove(stripByteCountsEntry);
                    newIFD.add(new TIFFEntry(useTiles ? TIFF.TAG_TILE_BYTE_COUNTS : TIFF.TAG_STRIP_BYTE_COUNTS, new int[]{(int) (jpegByteCounts[0])}));

                    newIFD.remove(oldJpegData);
                    newIFD.remove(oldJpegDataLength);
                    rearrangedByteStrips = true;
                }
                else if (oldJpegDataLength != null) {
                    // multiple bytestrips
                    // search for SOF on first strip and copy to each if needed
                    newIFD.remove(oldJpegData);
                    newIFD.remove(oldJpegDataLength);
                    stream.seek(jpegOffsets[0]);
                    byte[] jpegInterchangeData = new byte[(int) jpegByteCounts[0]];
                    stream.readFully(jpegInterchangeData);

                    stream.seek(offsets[0]);
                    byte[] sosMarker;
                    if (stream.read() == 0xff && stream.read() == 0xda) {
                        int sosLength = (stream.read() << 8) | stream.read();
                        sosMarker = new byte[sosLength + 2];
                        sosMarker[0] = (byte) 0xff;
                        sosMarker[1] = (byte) 0xda;
                        sosMarker[2] = (byte) ((sosLength & 0xff00) >> 8);
                        sosMarker[3] = (byte) (sosLength & 0xff);
                        stream.readFully(sosMarker, 4, sosLength - 2);
                    }
                    else {
                        throw new IOException("Old-style-JPEG with multiple strips are only supported, if first strip contains SOS");
                    }


                    newOffsets = new int[offsets.length];
                    int[] newByteCounts = new int[byteCounts.length];
                    for (int i = 0; i < offsets.length; i++) {
                        newOffsets[i] = (int) outputStream.getStreamPosition();
                        outputStream.write(jpegInterchangeData);
                        stream.seek(offsets[i]);

                        byte[] buffer = new byte[(int) byteCounts[i]];
                        newByteCounts[i] = (int) (jpegInterchangeData.length + byteCounts[i]);
                        stream.readFully(buffer);
                        if (buffer[0] != ((byte) 0xff) || buffer[1] != ((byte) 0xda)) {
                            outputStream.write(sosMarker);
                            newByteCounts[i] += sosMarker.length;
                        }
                        outputStream.write(buffer);
                    }

                    newIFD.remove(stripOffsetsEntry);
                    newIFD.add(new TIFFEntry(useTiles ? TIFF.TAG_TILE_OFFSETS : TIFF.TAG_STRIP_OFFSETS, newOffsets));
                    newIFD.remove(stripByteCountsEntry);
                    newIFD.add(new TIFFEntry(useTiles ? TIFF.TAG_TILE_BYTE_COUNTS : TIFF.TAG_STRIP_BYTE_COUNTS, newByteCounts));

                    newIFD.remove(oldJpegData);
                    newIFD.remove(oldJpegDataLength);
                    rearrangedByteStrips = true;
                }
            }
            else if (compression == TIFFExtension.COMPRESSION_OLD_JPEG) {
                // old-style but no JPEGInterchangeFormat

                long[] yCbCrSubSampling = getValueAsLongArray(IFD.getEntryById(TIFF.TAG_YCBCR_SUB_SAMPLING));
                int subsampling = yCbCrSubSampling != null
                        ? (int) ((yCbCrSubSampling[0] & 0xf) << 4 | yCbCrSubSampling[1] & 0xf)
                        : 0x22;
                int bands = ((Number) IFD.getEntryById(TIFF.TAG_SAMPLES_PER_PIXEL).getValue()).intValue();

                int w = ((Number) IFD.getEntryById(TIFF.TAG_IMAGE_WIDTH).getValue()).intValue();
                int h = ((Number) IFD.getEntryById(TIFF.TAG_IMAGE_HEIGHT).getValue()).intValue();

                int r = ((Number) (useTiles ? IFD.getEntryById(TIFF.TAG_TILE_HEIGTH) : IFD.getEntryById(TIFF.TAG_ROWS_PER_STRIP)).getValue()).intValue();
                int c = useTiles ? ((Number) IFD.getEntryById(TIFF.TAG_TILE_WIDTH).getValue()).intValue() : w;

                newOffsets = new int[offsets.length];
                int[] newByteCounts = new int[byteCounts.length];

                // No JPEGInterchangeFormat
                for (int i = 0; i < offsets.length; i++) {
                    byte[] start = new byte[2];
                    stream.seek(offsets[i]);
                    stream.readFully(start);
                    newOffsets[i] = (int) outputStream.getStreamPosition();
                    if (start[0] == ((byte) 0xff) && start[1] == ((byte) 0xd8)) {
                        // full image stream, nothing to do
                        writeData(stream, outputStream, offsets[i], byteCounts[i]);
                    }
                    else if (start[0] == ((byte) 0xff) && start[1] == ((byte) 0xda)) {
                        // starts with SOS
                        outputStream.writeShort(JPEG.SOI);
                        writeSOF0(outputStream, bands, c, r, subsampling);
                        writeData(stream, outputStream, offsets[i], byteCounts[i]);
                        outputStream.writeShort(JPEG.EOI);
                    }
                    else {
                        // raw data
                        outputStream.writeShort(JPEG.SOI);
                        writeSOF0(outputStream, bands, c, r, subsampling);
                        writeSOS(outputStream, bands);
                        writeData(stream, outputStream, offsets[i], byteCounts[i]);
                        outputStream.writeShort(JPEG.EOI);
                    }
                    newByteCounts[i] = ((int) outputStream.getStreamPosition()) - newOffsets[i];
                }

                newIFD.remove(stripOffsetsEntry);
                newIFD.add(new TIFFEntry(useTiles ? TIFF.TAG_TILE_OFFSETS : TIFF.TAG_STRIP_OFFSETS, newOffsets));
                newIFD.remove(stripByteCountsEntry);
                newIFD.add(new TIFFEntry(useTiles ? TIFF.TAG_TILE_BYTE_COUNTS : TIFF.TAG_STRIP_BYTE_COUNTS, newByteCounts));
                rearrangedByteStrips = true;
            }

            if (!rearrangedByteStrips && stripOffsetsEntry != null && stripByteCountsEntry != null) {
                newOffsets = writeData(offsets, byteCounts, outputStream);

                newIFD.remove(stripOffsetsEntry);
                newIFD.add(new TIFFEntry(useTiles ? TIFF.TAG_TILE_OFFSETS : TIFF.TAG_STRIP_OFFSETS, newOffsets));
            }

            if ((oldJpegData != null && newIFD.contains(oldJpegData)) || (oldJpegDataLength != null && newIFD.contains(oldJpegDataLength))) {
                throw new IOException("Failed to transform old-style JPEG");
            }

            Entry oldJpegTableQ, oldJpegTableDC, oldJpegTableAC;
            oldJpegTableQ = IFD.getEntryById(TIFF.TAG_OLD_JPEG_Q_TABLES);
            oldJpegTableDC = IFD.getEntryById(TIFF.TAG_OLD_JPEG_DC_TABLES);
            oldJpegTableAC = IFD.getEntryById(TIFF.TAG_OLD_JPEG_AC_TABLES);
            if ((oldJpegTableQ != null) || (oldJpegTableDC != null) || (oldJpegTableAC != null)) {
                if (IFD.getEntryById(TIFF.TAG_JPEG_TABLES) != null) {
                    throw new IOException("Found old-style and new-style JPEGTables");
                }

                boolean tablesInStream = jfifContainsTables(oldJpegTableQ, jpegOffsets, jpegByteCounts);
                tablesInStream &= jfifContainsTables(oldJpegTableDC, jpegOffsets, jpegByteCounts);
                tablesInStream &= jfifContainsTables(oldJpegTableAC, jpegOffsets, jpegByteCounts);
                if (!tablesInStream) {
                    // merge them only to JPEGTables if they are not already contained within the stream
                    Entry jpegTables = mergeTables(oldJpegTableQ, oldJpegTableDC, oldJpegTableAC);
                    if (jpegTables != null) {
                        newIFD.add(jpegTables);
                    }
                }
                if (oldJpegTableQ != null) {
                    newIFD.remove(oldJpegTableQ);
                }
                if (oldJpegTableDC != null) {
                    newIFD.remove(oldJpegTableDC);
                }
                if (oldJpegTableAC != null) {
                    newIFD.remove(oldJpegTableAC);
                }
            }

            if (compressionEntry != null && compression == TIFFExtension.COMPRESSION_OLD_JPEG) {
                newIFD.remove(compressionEntry);
                newIFD.add(new TIFFEntry(TIFF.TAG_COMPRESSION, TIFF.TYPE_SHORT, TIFFExtension.COMPRESSION_JPEG));
            }
            return newIFD;
        }

        //TODO merge/extract from TIFFReader Jpeg/6 stream reconstruction
        private void writeSOF0(ImageOutputStream outputStream, int bands, int width, int height, int subsampling) throws IOException {
            outputStream.writeShort(JPEG.SOF0); // TODO: Use correct process for data
            outputStream.writeShort(2 + 6 + 3 * bands); // SOF0 len
            outputStream.writeByte(8); // bits TODO: Consult raster/transfer type or BitsPerSample for 12/16 bits support
            outputStream.writeShort(height); // height
            outputStream.writeShort(width); // width
            outputStream.writeByte(bands); // Number of components

            for (int comp = 0; comp < bands; comp++) {
                outputStream.writeByte(comp); // Component id
                outputStream.writeByte(comp == 0 ? subsampling : 0x11); // h/v subsampling
                outputStream.writeByte(comp); // Q table selector TODO: Consider merging if tables are equal, correct selection if only 1 or 2 valid tables are contained
            }
        }

        //TODO merge/extract from TIFFReader Jpeg/6 stream reconstruction
        private void writeSOS(ImageOutputStream outputStream, int bands) throws IOException {
            outputStream.writeShort(JPEG.SOS);
            outputStream.writeShort(6 + 2 * bands); // SOS length
            outputStream.writeByte(bands); // Num comp

            for (int component = 0; component < bands; component++) {
                outputStream.writeByte(component); // Comp id
                outputStream.writeByte(component == 0 ? component : 0x10 + (component & 0xf)); // dc/ac selector TODO: correct selection if only 1 or 2 valid tables are contained
            }

            outputStream.writeByte(0); // Spectral selection start
            outputStream.writeByte(0); // Spectral selection end
            outputStream.writeByte(0); // Approx high & low
        }

        private void writeData(ImageInputStream input, ImageOutputStream output, long offset, long length) throws IOException {
            input.seek(offset);
            byte[] buffer = new byte[(int) length];
            stream.readFully(buffer);
            output.write(buffer);
        }

        private boolean jfifContainsTables(Entry tableEntry, long[] jpegOffsets, long[] jpegLengths) throws IOException {
            if (jpegLengths == null || jpegOffsets == null || jpegLengths.length == 0) return false;
            if (tableEntry != null) {
                long[] tableOffsets = getValueAsLongArray(tableEntry);
                for (long offset : tableOffsets) {
                    if (offset < jpegOffsets[0] || offset > (jpegOffsets[0] + jpegLengths[0])) {
                        return false;
                    }
                }
            }
            return true;
        }

        //TODO merge/extract from TIFFReader Jpeg/6 stream reconstruction
        private Entry mergeTables(Entry qEntry, Entry dcEntry, Entry acEntry) throws IOException {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            DataOutputStream dos = new DataOutputStream(bos);
            dos.writeShort(JPEG.SOI);

            if (qEntry != null && qEntry.valueCount() > 0) {
                long[] off = getValueAsLongArray(qEntry);
                byte[] table = new byte[64];
                for (int tableId = 0; tableId < off.length; tableId++) {
                    try {
                        stream.seek(off[tableId]);
                        stream.readFully(table);
                        dos.writeShort(JPEG.DQT);
                        dos.writeShort(3 + 64);
                        dos.writeByte(tableId);
                        dos.write(table);
                    } catch (EOFException e) {
                        // invalid table pointer, ignore
                    }
                }
            }

            // same marker for AC & DC tables, distinguished by flag in tableId
            if (dcEntry != null && dcEntry.valueCount() > 0) {
                long[] off = getValueAsLongArray(dcEntry);
                for (int tableId = 0; tableId < off.length; tableId++) {
                    try {
                        stream.seek(off[tableId]);
                        byte[] table = readHUFFTable();
                        if (table.length > (16 + 17)) {
                            // to long, table is invalid, just ignoe
                            continue;
                        }
                        dos.writeShort(JPEG.DHT);
                        dos.writeShort(3 + table.length);
                        dos.writeByte(tableId);
                        dos.write(table);
                    } catch (EOFException e) {
                        // invalid table pointer, ignore
                    }
                }
            }

            if (acEntry != null && acEntry.valueCount() > 0) {
                long[] off = getValueAsLongArray(acEntry);
                for (int tableId = 0; tableId < off.length; tableId++) {
                    try {
                        stream.seek(off[tableId]);
                        byte[] table = readHUFFTable();
                        if (table.length > (16 + 256)) {
                            // to long, table is invalid, just ignoe
                            continue;
                        }
                        dos.writeShort(JPEG.DHT);
                        dos.writeShort(3 + table.length);
                        dos.writeByte(16 | tableId);
                        dos.write(table);
                    } catch (EOFException e) {
                        // invalid table pointer, ignore
                    }
                }
            }

            dos.writeShort(JPEG.EOI);

            bos.close();
            if (bos.size() == 4) {
                // no valid tables, don't add
                return null;
            }

            return new TIFFEntry(TIFF.TAG_JPEG_TABLES, TIFF.TYPE_UNDEFINED, bos.toByteArray());
        }

        private byte[] readHUFFTable() throws IOException {
            byte[] lengths = new byte[16];
            stream.readFully(lengths);
            int numCodes = 0;
            for (int i = 0; i < lengths.length; i++) {
                numCodes += ((int) lengths[i]) & 0xff;
            }
            byte table[] = new byte[16 + numCodes];
            System.arraycopy(lengths, 0, table, 0, 16);
            stream.readFully(table, 16, numCodes);
            return table;
        }

        private int[] writeData(long[] offsets, long[] byteCounts, ImageOutputStream outputStream) throws IOException {
            int[] newOffsets = new int[offsets.length];
            for (int i = 0; i < offsets.length; i++) {
                newOffsets[i] = (int) outputStream.getStreamPosition();
                stream.seek(offsets[i]);

                byte[] buffer = new byte[(int) byteCounts[i]];
                try {
                    stream.readFully(buffer);
                } catch (EOFException e) {
                    // invalid strip length
                }
                outputStream.write(buffer);
            }
            return newOffsets;
        }

        private long[] getValueAsLongArray(Entry entry) throws IIOException {
            //TODO: code duplication from TIFFReader, should be extracted to metadata api
            long[] value;

            if (entry.valueCount() == 1) {
                // For single entries, this will be a boxed type
                value = new long[]{((Number) entry.getValue()).longValue()};
            }
            else if (entry.getValue() instanceof short[]) {
                short[] shorts = (short[]) entry.getValue();
                value = new long[shorts.length];

                for (int i = 0, length = value.length; i < length; i++) {
                    value[i] = shorts[i];
                }
            }
            else if (entry.getValue() instanceof int[]) {
                int[] ints = (int[]) entry.getValue();
                value = new long[ints.length];

                for (int i = 0, length = value.length; i < length; i++) {
                    value[i] = ints[i];
                }
            }
            else if (entry.getValue() instanceof long[]) {
                value = (long[]) entry.getValue();
            }
            else {
                throw new IIOException(String.format("Unsupported %s type: %s (%s)", entry.getFieldName(), entry.getTypeName(), entry.getValue().getClass()));
            }

            return value;
        }

        /**
         * Rotates the image by changing TIFF.TAG_ORIENTATION.
         * <p>
         * NOTICE: TIFF.TAG_ORIENTATION is an advice how the image is meant do
         * be displayed. Other metadata, such as width and height, relate to the
         * image as how it is stored. The ImageIO TIFF plugin does not handle
         * orientation. Use
         * {@link TIFFUtilities#applyOrientation(BufferedImage, int)} for
         * applying TIFF.TAG_ORIENTATION.
         * </p>
         *
         * @param degree Rotation amount, supports 90���, 180��� and 270���.
         */
        public void rotate(int degree) {
            Validate.isTrue(degree % 90 == 0 && degree > 0 && degree < 360,
                    "Only rotations by 90, 180 and 270 degree are supported");

            ArrayList<Entry> newIDFData = new ArrayList<>();
            Iterator<Entry> it = IFD.iterator();
            while (it.hasNext()) {
                newIDFData.add(it.next());
            }

            short orientation = TIFFBaseline.ORIENTATION_TOPLEFT;
            Entry orientationEntry = IFD.getEntryById(TIFF.TAG_ORIENTATION);
            if (orientationEntry != null) {
                orientation = ((Number) orientationEntry.getValue()).shortValue();
                newIDFData.remove(orientationEntry);
            }

            int steps = degree / 90;
            for (int i = 0; i < steps; i++) {
                switch (orientation) {
                    case TIFFBaseline.ORIENTATION_TOPLEFT:
                        orientation = TIFFExtension.ORIENTATION_RIGHTTOP;
                        break;
                    case TIFFExtension.ORIENTATION_TOPRIGHT:
                        orientation = TIFFExtension.ORIENTATION_RIGHTBOT;
                        break;
                    case TIFFExtension.ORIENTATION_BOTRIGHT:
                        orientation = TIFFExtension.ORIENTATION_LEFTBOT;
                        break;
                    case TIFFExtension.ORIENTATION_BOTLEFT:
                        orientation = TIFFExtension.ORIENTATION_LEFTTOP;
                        break;
                    case TIFFExtension.ORIENTATION_LEFTTOP:
                        orientation = TIFFExtension.ORIENTATION_TOPRIGHT;
                        break;
                    case TIFFExtension.ORIENTATION_RIGHTTOP:
                        orientation = TIFFExtension.ORIENTATION_BOTRIGHT;
                        break;
                    case TIFFExtension.ORIENTATION_RIGHTBOT:
                        orientation = TIFFExtension.ORIENTATION_BOTLEFT;
                        break;
                    case TIFFExtension.ORIENTATION_LEFTBOT:
                        orientation = TIFFBaseline.ORIENTATION_TOPLEFT;
                        break;
                }
            }

            newIDFData.add(new TIFFEntry(TIFF.TAG_ORIENTATION, (short) orientation));
            IFD = new IFD(newIDFData);
        }
    }

    public interface TIFFExtension {
        int ORIENTATION_TOPRIGHT = 2;
        int ORIENTATION_BOTRIGHT = 3;
        int ORIENTATION_BOTLEFT = 4;
        int ORIENTATION_LEFTTOP = 5;
        int ORIENTATION_RIGHTTOP = 6;
        int ORIENTATION_RIGHTBOT = 7;
        int ORIENTATION_LEFTBOT = 8;

        /**
         * Deprecated. For backwards compatibility only ("Old-style" JPEG).
         */
        int COMPRESSION_OLD_JPEG = 6;
        /**
         * JPEG Compression (lossy).
         */
        int COMPRESSION_JPEG = 7;
    }

    public interface TIFFBaseline {
        int ORIENTATION_TOPLEFT = 1;
    }
}