001/*
002 *  Licensed under the Apache License, Version 2.0 (the "License");
003 *  you may not use this file except in compliance with the License.
004 *  You may obtain a copy of the License at
005 *
006 *       http://www.apache.org/licenses/LICENSE-2.0
007 *
008 *  Unless required by applicable law or agreed to in writing, software
009 *  distributed under the License is distributed on an "AS IS" BASIS,
010 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
011 *  See the License for the specific language governing permissions and
012 *  limitations under the License.
013 */
014package org.apache.commons.imaging.formats.xpm;
015
016import static org.apache.commons.imaging.ImagingConstants.PARAM_KEY_FORMAT;
017
018import java.awt.Dimension;
019import java.awt.image.BufferedImage;
020import java.awt.image.ColorModel;
021import java.awt.image.DataBuffer;
022import java.awt.image.DirectColorModel;
023import java.awt.image.IndexColorModel;
024import java.awt.image.Raster;
025import java.awt.image.WritableRaster;
026import java.io.BufferedReader;
027import java.io.ByteArrayInputStream;
028import java.io.ByteArrayOutputStream;
029import java.io.IOException;
030import java.io.InputStream;
031import java.io.InputStreamReader;
032import java.io.OutputStream;
033import java.io.PrintWriter;
034import java.nio.charset.StandardCharsets;
035import java.util.ArrayList;
036import java.util.Arrays;
037import java.util.HashMap;
038import java.util.Locale;
039import java.util.Map;
040import java.util.Map.Entry;
041import java.util.Properties;
042import java.util.UUID;
043
044import org.apache.commons.imaging.ImageFormat;
045import org.apache.commons.imaging.ImageFormats;
046import org.apache.commons.imaging.ImageInfo;
047import org.apache.commons.imaging.ImageParser;
048import org.apache.commons.imaging.ImageReadException;
049import org.apache.commons.imaging.ImageWriteException;
050import org.apache.commons.imaging.common.BasicCParser;
051import org.apache.commons.imaging.common.ImageMetadata;
052import org.apache.commons.imaging.common.bytesource.ByteSource;
053import org.apache.commons.imaging.palette.PaletteFactory;
054import org.apache.commons.imaging.palette.SimplePalette;
055
056public class XpmImageParser extends ImageParser {
057    private static final String DEFAULT_EXTENSION = ".xpm";
058    private static final String[] ACCEPTED_EXTENSIONS = { ".xpm", };
059    private static Map<String, Integer> colorNames;
060    private static final char[] WRITE_PALETTE = { ' ', '.', 'X', 'o', 'O', '+',
061        '@', '#', '$', '%', '&', '*', '=', '-', ';', ':', '>', ',', '<',
062        '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'q', 'w', 'e',
063        'r', 't', 'y', 'u', 'i', 'p', 'a', 's', 'd', 'f', 'g', 'h', 'j',
064        'k', 'l', 'z', 'x', 'c', 'v', 'b', 'n', 'm', 'M', 'N', 'B', 'V',
065        'C', 'Z', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'P', 'I',
066        'U', 'Y', 'T', 'R', 'E', 'W', 'Q', '!', '~', '^', '/', '(', ')',
067        '_', '`', '\'', ']', '[', '{', '}', '|', };
068
069    private static void loadColorNames() throws ImageReadException {
070        synchronized (XpmImageParser.class) {
071            if (colorNames != null) {
072                return;
073            }
074
075            try {
076                final InputStream rgbTxtStream =
077                        XpmImageParser.class.getResourceAsStream("rgb.txt");
078                if (rgbTxtStream == null) {
079                    throw new ImageReadException("Couldn't find rgb.txt in our resources");
080                }
081                final Map<String, Integer> colors = new HashMap<>();
082                try (InputStreamReader isReader = new InputStreamReader(rgbTxtStream, StandardCharsets.US_ASCII);
083                        BufferedReader reader = new BufferedReader(isReader)) {
084                    String line;
085                    while ((line = reader.readLine()) != null) {
086                        if (line.charAt(0) == '!') {
087                            continue;
088                        }
089                        try {
090                            final int red = Integer.parseInt(line.substring(0, 3).trim());
091                            final int green = Integer.parseInt(line.substring(4, 7).trim());
092                            final int blue = Integer.parseInt(line.substring(8, 11).trim());
093                            final String colorName = line.substring(11).trim();
094                            colors.put(colorName.toLowerCase(Locale.ENGLISH), 0xff000000 | (red << 16)
095                                    | (green << 8) | blue);
096                        } catch (final NumberFormatException nfe) {
097                            throw new ImageReadException("Couldn't parse color in rgb.txt", nfe);
098                        }
099                    }
100                }
101                colorNames = colors;
102            } catch (final IOException ioException) {
103                throw new ImageReadException("Could not parse rgb.txt", ioException);
104            }
105        }
106    }
107
108    @Override
109    public String getName() {
110        return "X PixMap";
111    }
112
113    @Override
114    public String getDefaultExtension() {
115        return DEFAULT_EXTENSION;
116    }
117
118    @Override
119    protected String[] getAcceptedExtensions() {
120        return ACCEPTED_EXTENSIONS;
121    }
122
123    @Override
124    protected ImageFormat[] getAcceptedTypes() {
125        return new ImageFormat[] { ImageFormats.XPM, //
126        };
127    }
128
129    @Override
130    public ImageMetadata getMetadata(final ByteSource byteSource, final Map<String, Object> params)
131            throws ImageReadException, IOException {
132        return null;
133    }
134
135    @Override
136    public ImageInfo getImageInfo(final ByteSource byteSource, final Map<String, Object> params)
137            throws ImageReadException, IOException {
138        final XpmHeader xpmHeader = readXpmHeader(byteSource);
139        boolean transparent = false;
140        ImageInfo.ColorType colorType = ImageInfo.ColorType.BW;
141        for (final Entry<Object, PaletteEntry> entry : xpmHeader.palette.entrySet()) {
142            final PaletteEntry paletteEntry = entry.getValue();
143            if ((paletteEntry.getBestARGB() & 0xff000000) != 0xff000000) {
144                transparent = true;
145            }
146            if (paletteEntry.haveColor) {
147                colorType = ImageInfo.ColorType.RGB;
148            } else if (colorType != ImageInfo.ColorType.RGB
149                    && (paletteEntry.haveGray || paletteEntry.haveGray4Level)) {
150                colorType = ImageInfo.ColorType.GRAYSCALE;
151            }
152        }
153        return new ImageInfo("XPM version 3", xpmHeader.numCharsPerPixel * 8,
154                new ArrayList<String>(), ImageFormats.XPM,
155                "X PixMap", xpmHeader.height, "image/x-xpixmap", 1, 0, 0, 0, 0,
156                xpmHeader.width, false, transparent, true, colorType,
157                ImageInfo.CompressionAlgorithm.NONE);
158    }
159
160    @Override
161    public Dimension getImageSize(final ByteSource byteSource, final Map<String, Object> params)
162            throws ImageReadException, IOException {
163        final XpmHeader xpmHeader = readXpmHeader(byteSource);
164        return new Dimension(xpmHeader.width, xpmHeader.height);
165    }
166
167    @Override
168    public byte[] getICCProfileBytes(final ByteSource byteSource, final Map<String, Object> params)
169            throws ImageReadException, IOException {
170        return null;
171    }
172
173    private static class XpmHeader {
174        int width;
175        int height;
176        int numColors;
177        int numCharsPerPixel;
178        int xHotSpot = -1;
179        int yHotSpot = -1;
180        boolean xpmExt;
181
182        Map<Object, PaletteEntry> palette = new HashMap<>();
183
184        XpmHeader(final int width, final int height, final int numColors,
185                final int numCharsPerPixel, final int xHotSpot, final int yHotSpot, final boolean xpmExt) {
186            this.width = width;
187            this.height = height;
188            this.numColors = numColors;
189            this.numCharsPerPixel = numCharsPerPixel;
190            this.xHotSpot = xHotSpot;
191            this.yHotSpot = yHotSpot;
192            this.xpmExt = xpmExt;
193        }
194
195        public void dump(final PrintWriter pw) {
196            pw.println("XpmHeader");
197            pw.println("Width: " + width);
198            pw.println("Height: " + height);
199            pw.println("NumColors: " + numColors);
200            pw.println("NumCharsPerPixel: " + numCharsPerPixel);
201            if (xHotSpot != -1 && yHotSpot != -1) {
202                pw.println("X hotspot: " + xHotSpot);
203                pw.println("Y hotspot: " + yHotSpot);
204            }
205            pw.println("XpmExt: " + xpmExt);
206        }
207    }
208
209    private static class PaletteEntry {
210        int index;
211        boolean haveColor = false;
212        int colorArgb;
213        boolean haveGray = false;
214        int grayArgb;
215        boolean haveGray4Level = false;
216        int gray4LevelArgb;
217        boolean haveMono = false;
218        int monoArgb;
219
220        int getBestARGB() {
221            if (haveColor) {
222                return colorArgb;
223            } else if (haveGray) {
224                return grayArgb;
225            } else if (haveGray4Level) {
226                return gray4LevelArgb;
227            } else if (haveMono) {
228                return monoArgb;
229            } else {
230                return 0x00000000;
231            }
232        }
233    }
234
235    private static class XpmParseResult {
236        XpmHeader xpmHeader;
237        BasicCParser cParser;
238    }
239
240    private XpmHeader readXpmHeader(final ByteSource byteSource)
241            throws ImageReadException, IOException {
242        return parseXpmHeader(byteSource).xpmHeader;
243    }
244
245    private XpmParseResult parseXpmHeader(final ByteSource byteSource)
246            throws ImageReadException, IOException {
247        try (InputStream is = byteSource.getInputStream()) {
248            final StringBuilder firstComment = new StringBuilder();
249            final ByteArrayOutputStream preprocessedFile = BasicCParser.preprocess(
250                    is, firstComment, null);
251            if (!"XPM".equals(firstComment.toString().trim())) {
252                throw new ImageReadException("Parsing XPM file failed, "
253                        + "signature isn't '/* XPM */'");
254            }
255
256            final XpmParseResult xpmParseResult = new XpmParseResult();
257            xpmParseResult.cParser = new BasicCParser(new ByteArrayInputStream(
258                    preprocessedFile.toByteArray()));
259            xpmParseResult.xpmHeader = parseXpmHeader(xpmParseResult.cParser);
260            return xpmParseResult;
261        }
262    }
263
264    private boolean parseNextString(final BasicCParser cParser,
265            final StringBuilder stringBuilder) throws IOException, ImageReadException {
266        stringBuilder.setLength(0);
267        String token = cParser.nextToken();
268        if (token.charAt(0) != '"') {
269            throw new ImageReadException("Parsing XPM file failed, "
270                    + "no string found where expected");
271        }
272        BasicCParser.unescapeString(stringBuilder, token);
273        for (token = cParser.nextToken(); token.charAt(0) == '"'; token = cParser.nextToken()) {
274            BasicCParser.unescapeString(stringBuilder, token);
275        }
276        if (",".equals(token)) {
277            return true;
278        } else if ("}".equals(token)) {
279            return false;
280        } else {
281            throw new ImageReadException("Parsing XPM file failed, "
282                    + "no ',' or '}' found where expected");
283        }
284    }
285
286    private XpmHeader parseXpmValuesSection(final String row)
287            throws ImageReadException {
288        final String[] tokens = BasicCParser.tokenizeRow(row);
289        if (tokens.length < 4 || tokens.length > 7) {
290            throw new ImageReadException("Parsing XPM file failed, "
291                    + "<Values> section has incorrect tokens");
292        }
293        try {
294            final int width = Integer.parseInt(tokens[0]);
295            final int height = Integer.parseInt(tokens[1]);
296            final int numColors = Integer.parseInt(tokens[2]);
297            final int numCharsPerPixel = Integer.parseInt(tokens[3]);
298            int xHotSpot = -1;
299            int yHotSpot = -1;
300            boolean xpmExt = false;
301            if (tokens.length >= 6) {
302                xHotSpot = Integer.parseInt(tokens[4]);
303                yHotSpot = Integer.parseInt(tokens[5]);
304            }
305            if (tokens.length == 5 || tokens.length == 7) {
306                if ("XPMEXT".equals(tokens[tokens.length - 1])) {
307                    xpmExt = true;
308                } else {
309                    throw new ImageReadException("Parsing XPM file failed, "
310                            + "can't parse <Values> section XPMEXT");
311                }
312            }
313            return new XpmHeader(width, height, numColors, numCharsPerPixel,
314                    xHotSpot, yHotSpot, xpmExt);
315        } catch (final NumberFormatException nfe) {
316            throw new ImageReadException("Parsing XPM file failed, "
317                    + "error parsing <Values> section", nfe);
318        }
319    }
320
321    private int parseColor(String color) throws ImageReadException {
322        if (color.charAt(0) == '#') {
323            color = color.substring(1);
324            if (color.length() == 3) {
325                final int red = Integer.parseInt(color.substring(0, 1), 16);
326                final int green = Integer.parseInt(color.substring(1, 2), 16);
327                final int blue = Integer.parseInt(color.substring(2, 3), 16);
328                return 0xff000000 | (red << 20) | (green << 12) | (blue << 4);
329            } else if (color.length() == 6) {
330                return 0xff000000 | Integer.parseInt(color, 16);
331            } else if (color.length() == 9) {
332                final int red = Integer.parseInt(color.substring(0, 1), 16);
333                final int green = Integer.parseInt(color.substring(3, 4), 16);
334                final int blue = Integer.parseInt(color.substring(6, 7), 16);
335                return 0xff000000 | (red << 16) | (green << 8) | blue;
336            } else if (color.length() == 12) {
337                final int red = Integer.parseInt(color.substring(0, 1), 16);
338                final int green = Integer.parseInt(color.substring(4, 5), 16);
339                final int blue = Integer.parseInt(color.substring(8, 9), 16);
340                return 0xff000000 | (red << 16) | (green << 8) | blue;
341            } else if (color.length() == 24) {
342                final int red = Integer.parseInt(color.substring(0, 1), 16);
343                final int green = Integer.parseInt(color.substring(8, 9), 16);
344                final int blue = Integer.parseInt(color.substring(16, 17), 16);
345                return 0xff000000 | (red << 16) | (green << 8) | blue;
346            } else {
347                return 0x00000000;
348            }
349        } else if (color.charAt(0) == '%') {
350            throw new ImageReadException("HSV colors are not implemented "
351                    + "even in the XPM specification!");
352        } else if ("None".equals(color)) {
353            return 0x00000000;
354        } else {
355            loadColorNames();
356            final String colorLowercase = color.toLowerCase(Locale.ENGLISH);
357            if (colorNames.containsKey(colorLowercase)) {
358                return colorNames.get(colorLowercase);
359            }
360            return 0x00000000;
361        }
362    }
363
364    private void populatePaletteEntry(final PaletteEntry paletteEntry, final String key, final String color) throws ImageReadException {
365        if ("m".equals(key)) {
366            paletteEntry.monoArgb = parseColor(color);
367            paletteEntry.haveMono = true;
368        } else if ("g4".equals(key)) {
369            paletteEntry.gray4LevelArgb = parseColor(color);
370            paletteEntry.haveGray4Level = true;
371        } else if ("g".equals(key)) {
372            paletteEntry.grayArgb = parseColor(color);
373            paletteEntry.haveGray = true;
374        } else if ("s".equals(key)) {
375            paletteEntry.colorArgb = parseColor(color);
376            paletteEntry.haveColor = true;
377        } else if ("c".equals(key)) {
378            paletteEntry.colorArgb = parseColor(color);
379            paletteEntry.haveColor = true;
380        }
381    }
382
383    private void parsePaletteEntries(final XpmHeader xpmHeader, final BasicCParser cParser)
384            throws IOException, ImageReadException {
385        final StringBuilder row = new StringBuilder();
386        for (int i = 0; i < xpmHeader.numColors; i++) {
387            row.setLength(0);
388            final boolean hasMore = parseNextString(cParser, row);
389            if (!hasMore) {
390                throw new ImageReadException("Parsing XPM file failed, " + "file ended while reading palette");
391            }
392            final String name = row.substring(0, xpmHeader.numCharsPerPixel);
393            final String[] tokens = BasicCParser.tokenizeRow(row.substring(xpmHeader.numCharsPerPixel));
394            final PaletteEntry paletteEntry = new PaletteEntry();
395            paletteEntry.index = i;
396            int previousKeyIndex = Integer.MIN_VALUE;
397            final StringBuilder colorBuffer = new StringBuilder();
398            for (int j = 0; j < tokens.length; j++) {
399                final String token = tokens[j];
400                boolean isKey = false;
401                if (previousKeyIndex < (j - 1)
402                        && "m".equals(token)
403                        || "g4".equals(token)
404                        || "g".equals(token)
405                        || "c".equals(token)
406                        || "s".equals(token)) {
407                    isKey = true;
408                }
409                if (isKey) {
410                    if (previousKeyIndex >= 0) {
411                        final String key = tokens[previousKeyIndex];
412                        final String color = colorBuffer.toString();
413                        colorBuffer.setLength(0);
414                        populatePaletteEntry(paletteEntry, key, color);
415                    }
416                    previousKeyIndex = j;
417                } else {
418                    if (previousKeyIndex < 0) {
419                        break;
420                    }
421                    if (colorBuffer.length() > 0) {
422                        colorBuffer.append(' ');
423                    }
424                    colorBuffer.append(token);
425                }
426            }
427            if (previousKeyIndex >= 0 && colorBuffer.length() > 0) {
428                final String key = tokens[previousKeyIndex];
429                final String color = colorBuffer.toString();
430                colorBuffer.setLength(0);
431                populatePaletteEntry(paletteEntry, key, color);
432            }
433            xpmHeader.palette.put(name, paletteEntry);
434        }
435    }
436
437    private XpmHeader parseXpmHeader(final BasicCParser cParser)
438            throws ImageReadException, IOException {
439        String name;
440        String token;
441        token = cParser.nextToken();
442        if (!"static".equals(token)) {
443            throw new ImageReadException(
444                    "Parsing XPM file failed, no 'static' token");
445        }
446        token = cParser.nextToken();
447        if (!"char".equals(token)) {
448            throw new ImageReadException(
449                    "Parsing XPM file failed, no 'char' token");
450        }
451        token = cParser.nextToken();
452        if (!"*".equals(token)) {
453            throw new ImageReadException(
454                    "Parsing XPM file failed, no '*' token");
455        }
456        name = cParser.nextToken();
457        if (name == null) {
458            throw new ImageReadException(
459                    "Parsing XPM file failed, no variable name");
460        }
461        if (name.charAt(0) != '_' && !Character.isLetter(name.charAt(0))) {
462            throw new ImageReadException(
463                    "Parsing XPM file failed, variable name "
464                            + "doesn't start with letter or underscore");
465        }
466        for (int i = 0; i < name.length(); i++) {
467            final char c = name.charAt(i);
468            if (!Character.isLetterOrDigit(c) && c != '_') {
469                throw new ImageReadException(
470                        "Parsing XPM file failed, variable name "
471                                + "contains non-letter non-digit non-underscore");
472            }
473        }
474        token = cParser.nextToken();
475        if (!"[".equals(token)) {
476            throw new ImageReadException(
477                    "Parsing XPM file failed, no '[' token");
478        }
479        token = cParser.nextToken();
480        if (!"]".equals(token)) {
481            throw new ImageReadException(
482                    "Parsing XPM file failed, no ']' token");
483        }
484        token = cParser.nextToken();
485        if (!"=".equals(token)) {
486            throw new ImageReadException(
487                    "Parsing XPM file failed, no '=' token");
488        }
489        token = cParser.nextToken();
490        if (!"{".equals(token)) {
491            throw new ImageReadException(
492                    "Parsing XPM file failed, no '{' token");
493        }
494
495        final StringBuilder row = new StringBuilder();
496        final boolean hasMore = parseNextString(cParser, row);
497        if (!hasMore) {
498            throw new ImageReadException("Parsing XPM file failed, "
499                    + "file too short");
500        }
501        final XpmHeader xpmHeader = parseXpmValuesSection(row.toString());
502        parsePaletteEntries(xpmHeader, cParser);
503        return xpmHeader;
504    }
505
506    private BufferedImage readXpmImage(final XpmHeader xpmHeader, final BasicCParser cParser)
507            throws ImageReadException, IOException {
508        ColorModel colorModel;
509        WritableRaster raster;
510        int bpp;
511        if (xpmHeader.palette.size() <= (1 << 8)) {
512            final int[] palette = new int[xpmHeader.palette.size()];
513            for (final Entry<Object, PaletteEntry> entry : xpmHeader.palette.entrySet()) {
514                final PaletteEntry paletteEntry = entry.getValue();
515                palette[paletteEntry.index] = paletteEntry.getBestARGB();
516            }
517            colorModel = new IndexColorModel(8, xpmHeader.palette.size(),
518                    palette, 0, true, -1, DataBuffer.TYPE_BYTE);
519            raster = Raster.createInterleavedRaster(
520                    DataBuffer.TYPE_BYTE, xpmHeader.width, xpmHeader.height, 1,
521                    null);
522            bpp = 8;
523        } else if (xpmHeader.palette.size() <= (1 << 16)) {
524            final int[] palette = new int[xpmHeader.palette.size()];
525            for (final Entry<Object, PaletteEntry> entry : xpmHeader.palette.entrySet()) {
526                final PaletteEntry paletteEntry = entry.getValue();
527                palette[paletteEntry.index] = paletteEntry.getBestARGB();
528            }
529            colorModel = new IndexColorModel(16, xpmHeader.palette.size(),
530                    palette, 0, true, -1, DataBuffer.TYPE_USHORT);
531            raster = Raster.createInterleavedRaster(
532                    DataBuffer.TYPE_USHORT, xpmHeader.width, xpmHeader.height,
533                    1, null);
534            bpp = 16;
535        } else {
536            colorModel = new DirectColorModel(32, 0x00ff0000, 0x0000ff00,
537                    0x000000ff, 0xff000000);
538            raster = Raster.createPackedRaster(DataBuffer.TYPE_INT,
539                    xpmHeader.width, xpmHeader.height, new int[] { 0x00ff0000,
540                            0x0000ff00, 0x000000ff, 0xff000000 }, null);
541            bpp = 32;
542        }
543
544        final BufferedImage image = new BufferedImage(colorModel, raster,
545                colorModel.isAlphaPremultiplied(), new Properties());
546        final DataBuffer dataBuffer = raster.getDataBuffer();
547        final StringBuilder row = new StringBuilder();
548        boolean hasMore = true;
549        for (int y = 0; y < xpmHeader.height; y++) {
550            row.setLength(0);
551            hasMore = parseNextString(cParser, row);
552            if (y < (xpmHeader.height - 1) && !hasMore) {
553                throw new ImageReadException("Parsing XPM file failed, "
554                        + "insufficient image rows in file");
555            }
556            final int rowOffset = y * xpmHeader.width;
557            for (int x = 0; x < xpmHeader.width; x++) {
558                final String index = row.substring(x * xpmHeader.numCharsPerPixel,
559                        (x + 1) * xpmHeader.numCharsPerPixel);
560                final PaletteEntry paletteEntry = xpmHeader.palette.get(index);
561                if (paletteEntry == null) {
562                    throw new ImageReadException(
563                            "No palette entry was defined " + "for " + index);
564                }
565                if (bpp <= 16) {
566                    dataBuffer.setElem(rowOffset + x, paletteEntry.index);
567                } else {
568                    dataBuffer.setElem(rowOffset + x,
569                            paletteEntry.getBestARGB());
570                }
571            }
572        }
573
574        while (hasMore) {
575            row.setLength(0);
576            hasMore = parseNextString(cParser, row);
577        }
578
579        final String token = cParser.nextToken();
580        if (!";".equals(token)) {
581            throw new ImageReadException("Last token wasn't ';'");
582        }
583
584        return image;
585    }
586
587    @Override
588    public boolean dumpImageFile(final PrintWriter pw, final ByteSource byteSource)
589            throws ImageReadException, IOException {
590        readXpmHeader(byteSource).dump(pw);
591        return true;
592    }
593
594    @Override
595    public final BufferedImage getBufferedImage(final ByteSource byteSource,
596            final Map<String, Object> params) throws ImageReadException, IOException {
597        final XpmParseResult result = parseXpmHeader(byteSource);
598        return readXpmImage(result.xpmHeader, result.cParser);
599    }
600
601    private String randomName() {
602        final UUID uuid = UUID.randomUUID();
603        final StringBuilder stringBuilder = new StringBuilder("a");
604        long bits = uuid.getMostSignificantBits();
605        // Long.toHexString() breaks for very big numbers
606        for (int i = 64 - 8; i >= 0; i -= 8) {
607            stringBuilder.append(Integer.toHexString((int) ((bits >> i) & 0xff)));
608        }
609        bits = uuid.getLeastSignificantBits();
610        for (int i = 64 - 8; i >= 0; i -= 8) {
611            stringBuilder.append(Integer.toHexString((int) ((bits >> i) & 0xff)));
612        }
613        return stringBuilder.toString();
614    }
615
616    private String pixelsForIndex(int index, final int charsPerPixel) {
617        final StringBuilder stringBuilder = new StringBuilder();
618        int highestPower = 1;
619        for (int i = 1; i < charsPerPixel; i++) {
620            highestPower *= WRITE_PALETTE.length;
621        }
622        for (int i = 0; i < charsPerPixel; i++) {
623            final int multiple = index / highestPower;
624            index -= (multiple * highestPower);
625            highestPower /= WRITE_PALETTE.length;
626            stringBuilder.append(WRITE_PALETTE[multiple]);
627        }
628        return stringBuilder.toString();
629    }
630
631    private String toColor(final int color) {
632        final String hex = Integer.toHexString(color);
633        if (hex.length() < 6) {
634            final char[] zeroes = new char[6 - hex.length()];
635            Arrays.fill(zeroes, '0');
636            return "#" + new String(zeroes) + hex;
637        }
638        return "#" + hex;
639    }
640
641    @Override
642    public void writeImage(final BufferedImage src, final OutputStream os, Map<String, Object> params)
643            throws ImageWriteException, IOException {
644        // make copy of params; we'll clear keys as we consume them.
645        params = (params == null) ? new HashMap<>() : new HashMap<>(params);
646
647        // clear format key.
648        if (params.containsKey(PARAM_KEY_FORMAT)) {
649            params.remove(PARAM_KEY_FORMAT);
650        }
651
652        if (!params.isEmpty()) {
653            final Object firstKey = params.keySet().iterator().next();
654            throw new ImageWriteException("Unknown parameter: " + firstKey);
655        }
656
657        final PaletteFactory paletteFactory = new PaletteFactory();
658        boolean hasTransparency = false;
659        if (paletteFactory.hasTransparency(src, 1)) {
660            hasTransparency = true;
661        }
662        SimplePalette palette = null;
663        int maxColors = WRITE_PALETTE.length;
664        int charsPerPixel = 1;
665        while (palette == null) {
666            palette = paletteFactory.makeExactRgbPaletteSimple(src,
667                    hasTransparency ? maxColors - 1 : maxColors);
668
669            // leave the loop if numbers would go beyond Integer.MAX_VALUE to avoid infinite loops
670            // test every operation from below if it would increase an int value beyond Integer.MAX_VALUE
671            long nextMaxColors = maxColors * WRITE_PALETTE.length;
672            long nextCharsPerPixel = charsPerPixel + 1;
673            if (nextMaxColors > Integer.MAX_VALUE) {
674                throw new ImageWriteException("Xpm: Can't write images with more than Integer.MAX_VALUE colors.");
675            }
676            if (nextCharsPerPixel > Integer.MAX_VALUE) {
677                throw new ImageWriteException("Xpm: Can't write images with more than Integer.MAX_VALUE chars per pixel.");
678            }
679            // the code above makes sure that we never go beyond Integer.MAX_VALUE here
680            if (palette == null) {
681                maxColors *= WRITE_PALETTE.length;
682                charsPerPixel++;
683            }
684        }
685        int colors = palette.length();
686        if (hasTransparency) {
687            ++colors;
688        }
689
690        String line = "/* XPM */\n";
691        os.write(line.getBytes(StandardCharsets.US_ASCII));
692        line = "static char *" + randomName() + "[] = {\n";
693        os.write(line.getBytes(StandardCharsets.US_ASCII));
694        line = "\"" + src.getWidth() + " " + src.getHeight() + " " + colors
695                + " " + charsPerPixel + "\",\n";
696        os.write(line.getBytes(StandardCharsets.US_ASCII));
697
698        for (int i = 0; i < colors; i++) {
699            String color;
700            if (i < palette.length()) {
701                color = toColor(palette.getEntry(i));
702            } else {
703                color = "None";
704            }
705            line = "\"" + pixelsForIndex(i, charsPerPixel) + " c " + color
706                    + "\",\n";
707            os.write(line.getBytes(StandardCharsets.US_ASCII));
708        }
709
710        String separator = "";
711        for (int y = 0; y < src.getHeight(); y++) {
712            os.write(separator.getBytes(StandardCharsets.US_ASCII));
713            separator = ",\n";
714            line = "\"";
715            os.write(line.getBytes(StandardCharsets.US_ASCII));
716            for (int x = 0; x < src.getWidth(); x++) {
717                final int argb = src.getRGB(x, y);
718                if ((argb & 0xff000000) == 0) {
719                    line = pixelsForIndex(palette.length(), charsPerPixel);
720                } else {
721                    line = pixelsForIndex(
722                            palette.getPaletteIndex(0xffffff & argb),
723                            charsPerPixel);
724                }
725                os.write(line.getBytes(StandardCharsets.US_ASCII));
726            }
727            line = "\"";
728            os.write(line.getBytes(StandardCharsets.US_ASCII));
729        }
730
731        line = "\n};\n";
732        os.write(line.getBytes(StandardCharsets.US_ASCII));
733    }
734}