Jexer image protocol
[nikiroo-utils.git] / src / jexer / tterminal / ECMA48.java
index 2b957f1ad75f8493b073b91e2426d1384b05fc7e..c393ca50d46b3f5bd1909ff78e2d312f1f3d8cb7 100644 (file)
@@ -30,7 +30,9 @@ package jexer.tterminal;
 
 import java.awt.Graphics2D;
 import java.awt.image.BufferedImage;
+import java.io.BufferedInputStream;
 import java.io.BufferedOutputStream;
+import java.io.ByteArrayInputStream;
 import java.io.CharArrayWriter;
 import java.io.InputStream;
 import java.io.InputStreamReader;
@@ -45,6 +47,7 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
+import javax.imageio.ImageIO;
 
 import jexer.TKeypress;
 import jexer.backend.GlyphMaker;
@@ -256,7 +259,7 @@ public class ECMA48 implements Runnable {
     /**
      * The type of emulator to be.
      */
-    private DeviceType type = DeviceType.VT102;
+    private final DeviceType type;
 
     /**
      * The scrollback buffer characters + attributes.
@@ -394,7 +397,7 @@ public class ECMA48 implements Runnable {
     /**
      * Non-csi collect buffer.
      */
-    private StringBuilder collectBuffer;
+    private StringBuilder collectBuffer = new StringBuilder(128);
 
     /**
      * When true, use the G1 character set.
@@ -469,7 +472,12 @@ public class ECMA48 implements Runnable {
     /**
      * Sixel collection buffer.
      */
-    private StringBuilder sixelParseBuffer;
+    private StringBuilder sixelParseBuffer = new StringBuilder(2048);
+
+    /**
+     * Sixel shared palette.
+     */
+    private HashMap<Integer, java.awt.Color> sixelPalette;
 
     /**
      * The width of a character cell in pixels.
@@ -650,7 +658,8 @@ public class ECMA48 implements Runnable {
             this.inputStream  = new TimeoutInputStream(inputStream, 2000);
         }
         if (type == DeviceType.XTERM) {
-            this.input    = new InputStreamReader(this.inputStream, "UTF-8");
+            this.input    = new InputStreamReader(new BufferedInputStream(
+                this.inputStream, 1024 * 128), "UTF-8");
             this.output   = new OutputStreamWriter(new
                 BufferedOutputStream(outputStream), "UTF-8");
             this.outputStream = null;
@@ -755,11 +764,28 @@ public class ECMA48 implements Runnable {
                                 int ch = Character.codePointAt(readBufferUTF8,
                                     i);
                                 i += Character.charCount(ch);
-                                consume(ch);
+
+                                // Special case for VT10x: 7-bit characters
+                                // only.
+                                if ((type == DeviceType.VT100)
+                                    || (type == DeviceType.VT102)
+                                ) {
+                                    consume(ch & 0x7F);
+                                } else {
+                                    consume(ch);
+                                }
                             }
                         } else {
                             for (int i = 0; i < rc; i++) {
-                                consume(readBuffer[i]);
+                                // Special case for VT10x: 7-bit characters
+                                // only.
+                                if ((type == DeviceType.VT100)
+                                    || (type == DeviceType.VT102)
+                                ) {
+                                    consume(readBuffer[i] & 0x7F);
+                                } else {
+                                    consume(readBuffer[i]);
+                                }
                             }
                         }
                     }
@@ -869,12 +895,14 @@ public class ECMA48 implements Runnable {
 
         case VT220:
         case XTERM:
-            // "I am a VT220" - 7 bit version
+            // "I am a VT220" - 7 bit version, with sixel and Jexer image
+            // support.
             if (!s8c1t) {
-                return "\033[?62;1;6;9;4c";
+                return "\033[?62;1;6;9;4;22;444c";
             }
-            // "I am a VT220" - 8 bit version
-            return "\u009b?62;1;6;9;4c";
+            // "I am a VT220" - 8 bit version, with sixel and Jexer image
+            // support.
+            return "\u009b?62;1;6;9;4;22;444c";
         default:
             throw new IllegalArgumentException("Invalid device type: " + type);
         }
@@ -1235,7 +1263,7 @@ public class ECMA48 implements Runnable {
      */
     private void toGround() {
         csiParams.clear();
-        collectBuffer = new StringBuilder(8);
+        collectBuffer.setLength(0);
         scanState = ScanState.GROUND;
     }
 
@@ -3144,6 +3172,21 @@ public class ECMA48 implements Runnable {
 
                 break;
 
+            case 80:
+                if (type == DeviceType.XTERM) {
+                    if (decPrivateModeFlag == true) {
+                        if (value == true) {
+                            // Enable sixel scrolling (default).
+                            // TODO
+                        } else {
+                            // Disable sixel scrolling.
+                            // TODO
+                        }
+                    }
+                }
+
+                break;
+
             case 1000:
                 if ((type == DeviceType.XTERM)
                     && (decPrivateModeFlag == true)
@@ -3209,6 +3252,22 @@ public class ECMA48 implements Runnable {
                 }
                 break;
 
+            case 1070:
+                if (type == DeviceType.XTERM) {
+                    if (decPrivateModeFlag == true) {
+                        if (value == true) {
+                            // Use private color registers for each sixel
+                            // graphic (default).
+                            sixelPalette = null;
+                        } else {
+                            // Use shared color registers for each sixel
+                            // graphic.
+                            sixelPalette = new HashMap<Integer, java.awt.Color>();
+                        }
+                    }
+                }
+                break;
+
             default:
                 break;
 
@@ -4753,6 +4812,19 @@ public class ECMA48 implements Runnable {
                                 color.getBlue() << 8));
                     }
                 }
+
+                if (p[0].equals("444")) {
+                    if (p[1].equals("0") && (p.length == 6)) {
+                        // Jexer image - RGB
+                        parseJexerImageRGB(p[2], p[3], p[4], p[5]);
+                    } else if (p[1].equals("1") && (p.length == 4)) {
+                        // Jexer image - PNG
+                        parseJexerImageFile(1, p[2], p[3]);
+                    } else if (p[1].equals("2") && (p.length == 4)) {
+                        // Jexer image - JPG
+                        parseJexerImageFile(2, p[2], p[3]);
+                    }
+                }
             }
 
             // Go to SCAN_GROUND state
@@ -4861,16 +4933,11 @@ public class ECMA48 implements Runnable {
      *
      * @param ch character from the remote side
      */
-    private void consume(int ch) {
+    private void consume(final int ch) {
 
         // DEBUG
         // System.err.printf("%c STATE = %s\n", ch, scanState);
 
-        // Special case for VT10x: 7-bit characters only
-        if ((type == DeviceType.VT100) || (type == DeviceType.VT102)) {
-            ch = (ch & 0x7F);
-        }
-
         // Special "anywhere" states
 
         // 18, 1A                     --> execute, then switch to SCAN_GROUND
@@ -6643,7 +6710,7 @@ public class ECMA48 implements Runnable {
 
             // 0x71 goes to DCS_SIXEL
             if (ch == 0x71) {
-                sixelParseBuffer = new StringBuilder();
+                sixelParseBuffer.setLength(0);
                 scanState = ScanState.DCS_SIXEL;
             } else if ((ch >= 0x40) && (ch <= 0x7E)) {
                 // 0x40-7E goes to DCS_PASSTHROUGH
@@ -6728,7 +6795,7 @@ public class ECMA48 implements Runnable {
 
             // 0x71 goes to DCS_SIXEL
             if (ch == 0x71) {
-                sixelParseBuffer = new StringBuilder();
+                sixelParseBuffer.setLength(0);
                 scanState = ScanState.DCS_SIXEL;
             } else if ((ch >= 0x40) && (ch <= 0x7E)) {
                 // 0x40-7E goes to DCS_PASSTHROUGH
@@ -6791,11 +6858,13 @@ public class ECMA48 implements Runnable {
             if (ch == 0x9C) {
                 parseSixel();
                 toGround();
+                return;
             }
 
             // 0x1B 0x5C goes to GROUND
             if (ch == 0x1B) {
                 collect((char) ch);
+                return;
             }
             if (ch == 0x5C) {
                 if ((collectBuffer.length() > 0)
@@ -6803,29 +6872,20 @@ public class ECMA48 implements Runnable {
                 ) {
                     parseSixel();
                     toGround();
+                    return;
                 }
             }
 
             // 00-17, 19, 1C-1F, 20-7E   --> put
-            if (ch <= 0x17) {
-                sixelParseBuffer.append((char) ch);
-                return;
-            }
-            if (ch == 0x19) {
-                sixelParseBuffer.append((char) ch);
-                return;
-            }
-            if ((ch >= 0x1C) && (ch <= 0x1F)) {
-                sixelParseBuffer.append((char) ch);
-                return;
-            }
-            if ((ch >= 0x20) && (ch <= 0x7E)) {
+            if ((ch <= 0x17)
+                || (ch == 0x19)
+                || ((ch >= 0x1C) && (ch <= 0x1F))
+                || ((ch >= 0x20) && (ch <= 0x7E))
+            ) {
                 sixelParseBuffer.append((char) ch);
-                return;
             }
 
             // 7F                        --> ignore
-
             return;
 
         case SOSPMAPC_STRING:
@@ -6923,9 +6983,43 @@ public class ECMA48 implements Runnable {
         return mouseProtocol;
     }
 
-    // ------------------------------------------------------------------------
-    // Sixel support ----------------------------------------------------------
-    // ------------------------------------------------------------------------
+    /**
+     * Draw the left and right cells of a two-cell-wide (full-width) glyph.
+     *
+     * @param leftX the x position to draw the left half to
+     * @param leftY the y position to draw the left half to
+     * @param rightX the x position to draw the right half to
+     * @param rightY the y position to draw the right half to
+     * @param ch the character to draw
+     */
+    private void drawHalves(final int leftX, final int leftY,
+        final int rightX, final int rightY, final int ch) {
+
+        // System.err.println("drawHalves(): " + Integer.toHexString(ch));
+
+        if (lastTextHeight != textHeight) {
+            glyphMaker = GlyphMaker.getInstance(textHeight);
+            lastTextHeight = textHeight;
+        }
+
+        Cell cell = new Cell(ch, currentState.attr);
+        BufferedImage image = glyphMaker.getImage(cell, textWidth * 2,
+            textHeight);
+        BufferedImage leftImage = image.getSubimage(0, 0, textWidth,
+            textHeight);
+        BufferedImage rightImage = image.getSubimage(textWidth, 0, textWidth,
+            textHeight);
+
+        Cell left = new Cell(cell);
+        left.setImage(leftImage);
+        left.setWidth(Cell.Width.LEFT);
+        display.get(leftY).replace(leftX, left);
+
+        Cell right = new Cell(cell);
+        right.setImage(rightImage);
+        right.setWidth(Cell.Width.RIGHT);
+        display.get(rightY).replace(rightX, right);
+    }
 
     /**
      * Set the width of a character cell in pixels.
@@ -6954,9 +7048,9 @@ public class ECMA48 implements Runnable {
         /*
         System.err.println("parseSixel(): '" + sixelParseBuffer.toString()
             + "'");
-         */
+        */
 
-        Sixel sixel = new Sixel(sixelParseBuffer.toString());
+        Sixel sixel = new Sixel(sixelParseBuffer.toString(), sixelPalette);
         BufferedImage image = sixel.getImage();
 
         // System.err.println("parseSixel(): image " + image);
@@ -6965,6 +7059,169 @@ public class ECMA48 implements Runnable {
             // Sixel data was malformed in some way, bail out.
             return;
         }
+        if ((image.getWidth() < 1)
+            || (image.getWidth() > 10000)
+            || (image.getHeight() < 1)
+            || (image.getHeight() > 10000)
+        ) {
+            return;
+        }
+
+        imageToCells(image, true);
+    }
+
+    /**
+     * Parse a "Jexer" RGB image string into a bitmap image, and overlay that
+     * image onto the text cells.
+     *
+     * @param pw width token
+     * @param ph height token
+     * @param ps scroll token
+     * @param data pixel data
+     */
+    private void parseJexerImageRGB(final String pw, final String ph,
+        final String ps, final String data) {
+
+        int imageWidth = 0;
+        int imageHeight = 0;
+        boolean scroll = false;
+        try {
+            imageWidth = Integer.parseInt(pw);
+            imageHeight = Integer.parseInt(ph);
+        } catch (NumberFormatException e) {
+            // SQUASH
+            return;
+        }
+        if ((imageWidth < 1)
+            || (imageWidth > 10000)
+            || (imageHeight < 1)
+            || (imageHeight > 10000)
+        ) {
+            return;
+        }
+        if (ps.equals("1")) {
+            scroll = true;
+        } else if (ps.equals("0")) {
+            scroll = false;
+        } else {
+            return;
+        }
+
+        java.util.Base64.Decoder base64 = java.util.Base64.getDecoder();
+        byte [] bytes = base64.decode(data);
+        if (bytes.length != (imageWidth * imageHeight * 3)) {
+            return;
+        }
+
+        BufferedImage image = new BufferedImage(imageWidth, imageHeight,
+            BufferedImage.TYPE_INT_ARGB);
+
+        for (int x = 0; x < imageWidth; x++) {
+            for (int y = 0; y < imageHeight; y++) {
+                int red   = bytes[(y * imageWidth * 3) + (x * 3)    ];
+                if (red < 0) {
+                    red += 256;
+                }
+                int green = bytes[(y * imageWidth * 3) + (x * 3) + 1];
+                if (green < 0) {
+                    green += 256;
+                }
+                int blue  = bytes[(y * imageWidth * 3) + (x * 3) + 2];
+                if (blue < 0) {
+                    blue += 256;
+                }
+                int rgb = 0xFF000000 | (red << 16) | (green << 8) | blue;
+                image.setRGB(x, y, rgb);
+            }
+        }
+
+        imageToCells(image, scroll);
+    }
+
+    /**
+     * Parse a "Jexer" PNG or JPG image string into a bitmap image, and
+     * overlay that image onto the text cells.
+     *
+     * @param type 1 for PNG, 2 for JPG
+     * @param ps scroll token
+     * @param data pixel data
+     */
+    private void parseJexerImageFile(final int type, final String ps,
+        final String data) {
+
+        int imageWidth = 0;
+        int imageHeight = 0;
+        boolean scroll = false;
+        BufferedImage image = null;
+        try {
+            java.util.Base64.Decoder base64 = java.util.Base64.getDecoder();
+            byte [] bytes = base64.decode(data);
+
+            switch (type) {
+            case 1:
+                if ((bytes[0] != (byte) 0x89)
+                    || (bytes[1] != 'P')
+                    || (bytes[2] != 'N')
+                    || (bytes[3] != 'G')
+                    || (bytes[4] != (byte) 0x0D)
+                    || (bytes[5] != (byte) 0x0A)
+                    || (bytes[6] != (byte) 0x1A)
+                    || (bytes[7] != (byte) 0x0A)
+                ) {
+                    // File does not have PNG header, bail out.
+                    return;
+                }
+                break;
+
+            case 2:
+                if ((bytes[0] != (byte) 0XFF)
+                    || (bytes[1] != (byte) 0xD8)
+                    || (bytes[2] != (byte) 0xFF)
+                ) {
+                    // File does not have JPG header, bail out.
+                    return;
+                }
+                break;
+
+            default:
+                // Unsupported type, bail out.
+                return;
+            }
+
+            image = ImageIO.read(new ByteArrayInputStream(bytes));
+        } catch (IOException e) {
+            // SQUASH
+            return;
+        }
+        assert (image != null);
+        imageWidth = image.getWidth();
+        imageHeight = image.getHeight();
+        if ((imageWidth < 1)
+            || (imageWidth > 10000)
+            || (imageHeight < 1)
+            || (imageHeight > 10000)
+        ) {
+            return;
+        }
+        if (ps.equals("1")) {
+            scroll = true;
+        } else if (ps.equals("0")) {
+            scroll = false;
+        } else {
+            return;
+        }
+
+        imageToCells(image, scroll);
+    }
+
+    /**
+     * Break up an image into the cells at the current cursor.
+     *
+     * @param image the image to display
+     * @param scroll if true, scroll the image and move the cursor
+     */
+    private void imageToCells(final BufferedImage image, final boolean scroll) {
+        assert (image != null);
 
         /*
          * Procedure:
@@ -7021,64 +7278,41 @@ public class ECMA48 implements Runnable {
         }
 
         int x0 = currentState.cursorX;
+        int y0 = currentState.cursorY;
         for (int y = 0; y < cellRows; y++) {
             for (int x = 0; x < cellColumns; x++) {
-                printCharacter(' ');
-                cursorLeft(1, false);
-                if ((x == cellColumns - 1) || (y == cellRows - 1)) {
-                    // TODO: render text of current cell first, then image
-                    // over it.  For now, just copy the cell.
-                    DisplayLine line = display.get(currentState.cursorY);
-                    line.replace(currentState.cursorX, cells[x][y]);
-                } else {
-                    // Copy the image cell into the display.
-                    DisplayLine line = display.get(currentState.cursorY);
-                    line.replace(currentState.cursorX, cells[x][y]);
+                assert (currentState.cursorX <= rightMargin);
+
+                // TODO: Render text of current cell first, then image over
+                // it (accounting for blank pixels).  For now, just copy the
+                // cell.
+                DisplayLine line = display.get(currentState.cursorY);
+                line.replace(currentState.cursorX, cells[x][y]);
+
+                // If at the end of the visible screen, stop.
+                if (currentState.cursorX == rightMargin) {
+                    break;
                 }
-                cursorRight(1, false);
+                // Room for more image on the visible screen.
+                currentState.cursorX++;
+            }
+            if (currentState.cursorY < scrollRegionBottom - 1) {
+                // Not at the bottom, down a line.
+                linefeed();
+            } else if (scroll == true) {
+                // At the bottom, scroll as needed.
+                linefeed();
+            } else {
+                // At the bottom, no more scrolling, done.
+                break;
             }
-            linefeed();
             cursorPosition(currentState.cursorY, x0);
         }
 
-    }
-
-    /**
-     * Draw the left and right cells of a two-cell-wide (full-width) glyph.
-     *
-     * @param leftX the x position to draw the left half to
-     * @param leftY the y position to draw the left half to
-     * @param rightX the x position to draw the right half to
-     * @param rightY the y position to draw the right half to
-     * @param ch the character to draw
-     */
-    private void drawHalves(final int leftX, final int leftY,
-        final int rightX, final int rightY, final int ch) {
-
-        // System.err.println("drawHalves(): " + Integer.toHexString(ch));
-
-        if (lastTextHeight != textHeight) {
-            glyphMaker = GlyphMaker.getInstance(textHeight);
-            lastTextHeight = textHeight;
+        if (scroll == false) {
+            cursorPosition(y0, x0);
         }
 
-        Cell cell = new Cell(ch, currentState.attr);
-        BufferedImage image = glyphMaker.getImage(cell, textWidth * 2,
-            textHeight);
-        BufferedImage leftImage = image.getSubimage(0, 0, textWidth,
-            textHeight);
-        BufferedImage rightImage = image.getSubimage(textWidth, 0, textWidth,
-            textHeight);
-
-        Cell left = new Cell(cell);
-        left.setImage(leftImage);
-        left.setWidth(Cell.Width.LEFT);
-        display.get(leftY).replace(leftX, left);
-
-        Cell right = new Cell(cell);
-        right.setImage(rightImage);
-        right.setWidth(Cell.Width.RIGHT);
-        display.get(rightY).replace(rightX, right);
     }
 
 }