From 9588c7134280341ab6e92e37d1c1d00b3756cee5 Mon Sep 17 00:00:00 2001 From: Kevin Lamonte Date: Mon, 5 Aug 2019 23:15:58 -0500 Subject: [PATCH] #35 CJK font wip --- src/jexer/TTerminalWindow.java | 8 +- src/jexer/backend/GlyphMaker.java | 312 ++++++++++++++++-------------- src/jexer/bits/Cell.java | 54 +++++- src/jexer/bits/StringUtils.java | 68 +++++++ src/jexer/tterminal/ECMA48.java | 112 +++++++++-- 5 files changed, 392 insertions(+), 162 deletions(-) diff --git a/src/jexer/TTerminalWindow.java b/src/jexer/TTerminalWindow.java index c745635..6d06fc8 100644 --- a/src/jexer/TTerminalWindow.java +++ b/src/jexer/TTerminalWindow.java @@ -359,6 +359,12 @@ public class TTerminalWindow extends TScrollableWindow } for (int i = 0; i < widthMax; i++) { Cell ch = line.charAt(i); + + if (ch.isImage()) { + putCharXY(i + 1, row, ch); + continue; + } + Cell newCell = new Cell(); newCell.setTo(ch); boolean reverse = line.isReverseColor() ^ ch.isReverse(); @@ -1032,7 +1038,7 @@ public class TTerminalWindow extends TScrollableWindow * The double-width font will be 2x this value. */ private void setupFont(final int fontSize) { - doubleFont = GlyphMaker.getDefault().size(fontSize * 2); + doubleFont = GlyphMaker.getInstance(fontSize * 2); // Special case: the ECMA48 backend needs to have a timer to drive // its blink state. diff --git a/src/jexer/backend/GlyphMaker.java b/src/jexer/backend/GlyphMaker.java index 0094145..0c798b1 100644 --- a/src/jexer/backend/GlyphMaker.java +++ b/src/jexer/backend/GlyphMaker.java @@ -40,24 +40,14 @@ import java.util.HashMap; import jexer.bits.Cell; /** - * GlyphMaker creates glyphs as bitmaps from a font. + * GlyphMakerFont creates glyphs as bitmaps from a font. */ -public class GlyphMaker { +class GlyphMakerFont { // ------------------------------------------------------------------------ // Constants -------------------------------------------------------------- // ------------------------------------------------------------------------ - /** - * The mono font resource filename (terminus). - */ - public static final String MONO = "terminus-ttf-4.39/TerminusTTF-Bold-4.39.ttf"; - - /** - * The CJK font resource filename. - */ - public static final String CJK = "NotoSansMonoCJKhk-Regular.otf"; - // ------------------------------------------------------------------------ // Variables -------------------------------------------------------------- // ------------------------------------------------------------------------ @@ -67,16 +57,6 @@ public class GlyphMaker { */ private static boolean DEBUG = false; - /** - * The instance that has the mono (default) font. - */ - private static GlyphMaker INSTANCE_MONO; - - /** - * The instance that has the CJK font. - */ - private static GlyphMaker INSTANCE_CJK; - /** * If true, we were successful at getting the font dimensions. */ @@ -87,11 +67,6 @@ public class GlyphMaker { */ private Font font = null; - /** - * The currently selected font size in points. - */ - private int fontSize = 16; - /** * Width of a character cell in pixels. */ @@ -153,133 +128,32 @@ public class GlyphMaker { // Constructors ----------------------------------------------------------- // ------------------------------------------------------------------------ - /** - * Private constructor used by the static instance methods. - * - * @param font the font to use - */ - private GlyphMaker(final Font font) { - this.font = font; - fontSize = font.getSize(); - } - /** * Public constructor. * - * @param fontName the name of the font to use + * @param filename the resource filename of the font to use * @param fontSize the size of font to use */ - public GlyphMaker(final String fontName, final int fontSize) { - font = new Font(fontName, Font.PLAIN, fontSize); + public GlyphMakerFont(final String filename, final int fontSize) { + Font fontRoot = null; + try { + ClassLoader loader = Thread.currentThread().getContextClassLoader(); + InputStream in = loader.getResourceAsStream(filename); + fontRoot = Font.createFont(Font.TRUETYPE_FONT, in); + font = fontRoot.deriveFont(Font.PLAIN, fontSize); + } catch (java.awt.FontFormatException e) { + e.printStackTrace(); + font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize); + } catch (java.io.IOException e) { + e.printStackTrace(); + font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize); + } } // ------------------------------------------------------------------------ - // GlyphMaker ------------------------------------------------------------- + // GlyphMakerFont --------------------------------------------------------- // ------------------------------------------------------------------------ - /** - * Obtain the GlyphMaker instance that uses the default monospace font. - * - * @return the instance - */ - public static GlyphMaker getDefault() { - - synchronized (GlyphMaker.class) { - if (INSTANCE_MONO != null) { - return INSTANCE_MONO; - } - - int fallbackFontSize = 16; - Font monoRoot = null; - try { - ClassLoader loader = Thread.currentThread().getContextClassLoader(); - InputStream in = loader.getResourceAsStream(MONO); - monoRoot = Font.createFont(Font.TRUETYPE_FONT, in); - } catch (java.awt.FontFormatException e) { - e.printStackTrace(); - monoRoot = new Font(Font.MONOSPACED, Font.PLAIN, - fallbackFontSize); - } catch (java.io.IOException e) { - e.printStackTrace(); - monoRoot = new Font(Font.MONOSPACED, Font.PLAIN, - fallbackFontSize); - } - INSTANCE_MONO = new GlyphMaker(monoRoot); - return INSTANCE_MONO; - } - } - - /** - * Obtain the GlyphMaker instance that uses the CJK font. - * - * @return the instance - */ - public static GlyphMaker getCJK() { - - synchronized (GlyphMaker.class) { - if (INSTANCE_CJK != null) { - return INSTANCE_CJK; - } - - int fallbackFontSize = 16; - Font cjkRoot = null; - try { - ClassLoader loader = Thread.currentThread().getContextClassLoader(); - InputStream in = loader.getResourceAsStream(CJK); - cjkRoot = Font.createFont(Font.TRUETYPE_FONT, in); - } catch (java.awt.FontFormatException e) { - e.printStackTrace(); - cjkRoot = new Font(Font.MONOSPACED, Font.PLAIN, - fallbackFontSize); - } catch (java.io.IOException e) { - e.printStackTrace(); - cjkRoot = new Font(Font.MONOSPACED, Font.PLAIN, - fallbackFontSize); - } - INSTANCE_CJK = new GlyphMaker(cjkRoot); - return INSTANCE_CJK; - } - } - - /** - * Obtain the GlyphMaker instance that uses the correct font for this - * character. - * - * @param ch the character - * @return the instance - */ - public static GlyphMaker getInstance(final int ch) { - if (((ch >= 0x4e00) && (ch <= 0x9fff)) - || ((ch >= 0x3400) && (ch <= 0x4dbf)) - || ((ch >= 0x20000) && (ch <= 0x2ebef)) - ) { - return getCJK(); - } - return getDefault(); - } - - /** - * Get a derived font at a specific size. - * - * @param fontSize the size to use - * @return a new instance at that font size - */ - public GlyphMaker size(final int fontSize) { - GlyphMaker maker = new GlyphMaker(font.deriveFont(Font.PLAIN, - fontSize)); - return maker; - } - - /** - * Get a glyph image, using the font's idea of cell width and height. - * - * @param cell the character to draw - * @return the glyph as an image - */ - public BufferedImage getImage(final Cell cell) { - return getImage(cell, textWidth, textHeight, true); - } - /** * Get a glyph image. * @@ -375,8 +249,8 @@ public class GlyphMaker { glyphCacheBlink = new HashMap(); glyphCache = new HashMap(); - BufferedImage image = new BufferedImage(fontSize * 2, fontSize * 2, - BufferedImage.TYPE_INT_ARGB); + BufferedImage image = new BufferedImage(font.getSize() * 2, + font.getSize() * 2, BufferedImage.TYPE_INT_ARGB); Graphics2D gr = image.createGraphics(); gr.setFont(font); FontMetrics fm = gr.getFontMetrics(); @@ -398,3 +272,149 @@ public class GlyphMaker { } } + +/** + * GlyphMaker presents unified interface to all of its supported fonts to + * clients. + */ +public class GlyphMaker { + + // ------------------------------------------------------------------------ + // Constants -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * The mono font resource filename (terminus). + */ + private static final String MONO = "terminus-ttf-4.39/TerminusTTF-Bold-4.39.ttf"; + + /** + * The CJKhk font resource filename. + */ + // private static final String CJKhk = "NotoSansMonoCJKhk-Regular.otf"; + + /** + * The CJKkr font resource filename. + */ + // private static final String CJKkr = "NotoSansMonoCJKkr-Regular.otf"; + + /** + * The CJKtc font resource filename. + */ + private static final String CJKtc = "NotoSansMonoCJKtc-Regular.otf"; + + // ------------------------------------------------------------------------ + // Variables -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * If true, enable debug messages. + */ + private static boolean DEBUG = false; + + /** + * Cache of font bundles by size. + */ + private static HashMap makers = new HashMap(); + + /** + * The instance that has the mono (default) font. + */ + private GlyphMakerFont makerMono; + + /** + * The instance that has the CJKhk font. + */ + // private GlyphMakerFont makerCJKhk; + + /** + * The instance that has the CJKkr font. + */ + // private GlyphMakerFont makerCJKkr; + + /** + * The instance that has the CJKtc font. + */ + private GlyphMakerFont makerCJKtc; + + // ------------------------------------------------------------------------ + // Constructors ----------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Create an instance with references to the necessary fonts. + * + * @param fontSize the size of these fonts in pixels + */ + private GlyphMaker(final int fontSize) { + makerMono = new GlyphMakerFont(MONO, fontSize); + // makerCJKhk = new GlyphMakerFont(CJKhk, fontSize); + // makerCJKkr = new GlyphMakerFont(CJKkr, fontSize); + makerCJKtc = new GlyphMakerFont(CJKtc, fontSize); + } + + // ------------------------------------------------------------------------ + // GlyphMaker ------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Obtain the GlyphMaker instance for a particular font size. + * + * @param fontSize the size of these fonts in pixels + * @return the instance + */ + public static GlyphMaker getInstance(final int fontSize) { + synchronized (GlyphMaker.class) { + GlyphMaker maker = makers.get(fontSize); + if (maker == null) { + maker = new GlyphMaker(fontSize); + makers.put(fontSize, maker); + } + return maker; + } + } + + /** + * Get a glyph image. + * + * @param cell the character to draw + * @param cellWidth the width of the text cell to draw into + * @param cellHeight the height of the text cell to draw into + * @return the glyph as an image + */ + public BufferedImage getImage(final Cell cell, final int cellWidth, + final int cellHeight) { + + return getImage(cell, cellWidth, cellHeight, true); + } + + /** + * Get a glyph image. + * + * @param cell the character to draw + * @param cellWidth the width of the text cell to draw into + * @param cellHeight the height of the text cell to draw into + * @param blinkVisible if true, the cell is visible if it is blinking + * @return the glyph as an image + */ + public BufferedImage getImage(final Cell cell, final int cellWidth, + final int cellHeight, final boolean blinkVisible) { + + char ch = cell.getChar(); + /* + if ((ch >= 0x4e00) && (ch <= 0x9fff)) { + return makerCJKhk.getImage(cell, cellWidth, cellHeight, blinkVisible); + } + if ((ch >= 0x4e00) && (ch <= 0x9fff)) { + return makerCJKkr.getImage(cell, cellWidth, cellHeight, blinkVisible); + } + */ + if ((ch >= 0x2e80) && (ch <= 0x9fff)) { + return makerCJKtc.getImage(cell, cellWidth, cellHeight, blinkVisible); + } + + // When all else fails, use the default. + return makerMono.getImage(cell, cellWidth, cellHeight, blinkVisible); + } + +} diff --git a/src/jexer/bits/Cell.java b/src/jexer/bits/Cell.java index d4c816f..5db5f43 100644 --- a/src/jexer/bits/Cell.java +++ b/src/jexer/bits/Cell.java @@ -40,6 +40,26 @@ public final class Cell extends CellAttributes { // Constants -------------------------------------------------------------- // ------------------------------------------------------------------------ + /** + * How this cell needs to be displayed if it is part of a larger glyph. + */ + public enum Width { + /** + * This cell is an entire glyph on its own. + */ + SINGLE, + + /** + * This cell is the left half of a wide glyph. + */ + LEFT, + + /** + * This cell is the right half of a wide glyph. + */ + RIGHT, + } + /** * The special "this cell is unset" (null) value. This is the Unicode * "not a character" value. @@ -55,6 +75,11 @@ public final class Cell extends CellAttributes { */ private char ch; + /** + * The display width of this cell. + */ + private Width width = Width.SINGLE; + /** * The image at this cell. */ @@ -113,7 +138,6 @@ public final class Cell extends CellAttributes { // Cell ------------------------------------------------------------------- // ------------------------------------------------------------------------ - /** * Set the image data for this cell. * @@ -122,6 +146,7 @@ public final class Cell extends CellAttributes { public void setImage(final BufferedImage image) { this.image = image; imageHashCode = image.hashCode(); + width = Width.SINGLE; } /** @@ -223,6 +248,25 @@ public final class Cell extends CellAttributes { this.ch = ch; } + /** + * Getter for cell width. + * + * @return Width.SINGLE, Width.LEFT, or Width.RIGHT + */ + public Width getWidth() { + return width; + } + + /** + * Setter for cell width. + * + * @param ch new cell width, one of Width.SINGLE, Width.LEFT, or + * Width.RIGHT + */ + public void setWidth(final Width width) { + this.width = width; + } + /** * Reset this cell to a blank. */ @@ -230,6 +274,7 @@ public final class Cell extends CellAttributes { public void reset() { super.reset(); ch = ' '; + width = Width.SINGLE; image = null; imageHashCode = 0; invertedImage = null; @@ -244,6 +289,7 @@ public final class Cell extends CellAttributes { public void unset() { super.reset(); ch = UNSET_VALUE; + width = Width.SINGLE; image = null; imageHashCode = 0; invertedImage = null; @@ -271,6 +317,7 @@ public final class Cell extends CellAttributes { && !isProtect() && !isRGB() && !isImage() + && (width == Width.SINGLE) && (ch == ' ') ) { return true; @@ -327,7 +374,7 @@ public final class Cell extends CellAttributes { } // Normal case: character and attributes must match. - if (ch == that.ch) { + if ((ch == that.ch) && (width == that.width)) { return super.equals(rhs); } return false; @@ -345,6 +392,7 @@ public final class Cell extends CellAttributes { int hash = A; hash = (B * hash) + super.hashCode(); hash = (B * hash) + (int)ch; + hash = (B * hash) + width.hashCode(); if (image != null) { /* hash = (B * hash) + image.hashCode(); @@ -371,11 +419,13 @@ public final class Cell extends CellAttributes { this.image = null; this.imageHashCode = 0; this.backgroundHashCode = 0; + this.width = Width.SINGLE; super.setTo(thatAttr); if (rhs instanceof Cell) { Cell that = (Cell) rhs; this.ch = that.ch; + this.width = that.width; this.image = that.image; this.invertedImage = that.invertedImage; this.background = that.background; diff --git a/src/jexer/bits/StringUtils.java b/src/jexer/bits/StringUtils.java index a98756e..d71fd31 100644 --- a/src/jexer/bits/StringUtils.java +++ b/src/jexer/bits/StringUtils.java @@ -404,4 +404,72 @@ public class StringUtils { return result.toString(); } + /** + * Determine display width of a Unicode code point. + * + * @param ch the code point, can be char + * @return the number of text cell columns required to display this code + * point, one of 0, 1, or 2 + */ + public static int width(final int ch) { + /* + * This routine is a modified version of mk_wcwidth() available + * at: http://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c + * + * The combining characters list has been omitted from this + * implementation. Hopefully no users will be impacted. + */ + + // 8-bit control characters: width 0 + if (ch == 0) { + return 0; + } + if ((ch < 32) || ((ch >= 0x7f) && (ch < 0xa0))) { + return 0; + } + + // All others: either 1 or 2 + if ((ch >= 0x1100) + && ((ch <= 0x115f) + // Hangul Jamo init. consonants + || (ch == 0x2329) + || (ch == 0x232a) + // CJK ... Yi + || ((ch >= 0x2e80) && (ch <= 0xa4cf) && (ch != 0x303f)) + // Hangul Syllables + || ((ch >= 0xac00) && (ch <= 0xd7a3)) + // CJK Compatibility Ideographs + || ((ch >= 0xf900) && (ch <= 0xfaff)) + // Vertical forms + || ((ch >= 0xfe10) && (ch <= 0xfe19)) + // CJK Compatibility Forms + || ((ch >= 0xfe30) && (ch <= 0xfe6f)) + // Fullwidth Forms + || ((ch >= 0xff00) && (ch <= 0xff60)) + || ((ch >= 0xffe0) && (ch <= 0xffe6)) + || ((ch >= 0x20000) && (ch <= 0x2fffd)) + || ((ch >= 0x30000) && (ch <= 0x3fffd)) + // TODO: emoji / twemoji + ) + ) { + return 2; + } + return 1; + } + + /** + * Determine display width of a string. This ASSUMES that no characters + * are combining. Hopefully no users will be impacted. + * + * @param str the string + * @return the number of text cell columns required to display this string + */ + public static int width(final String str) { + int n = 0; + for (int i = 0; i < str.length(); i++) { + n += width(str.charAt(i)); + } + return n; + } + } diff --git a/src/jexer/tterminal/ECMA48.java b/src/jexer/tterminal/ECMA48.java index c94d7d2..f00164b 100644 --- a/src/jexer/tterminal/ECMA48.java +++ b/src/jexer/tterminal/ECMA48.java @@ -47,10 +47,12 @@ import java.util.HashMap; import java.util.List; import jexer.TKeypress; -import jexer.event.TMouseEvent; +import jexer.backend.GlyphMaker; import jexer.bits.Color; import jexer.bits.Cell; import jexer.bits.CellAttributes; +import jexer.bits.StringUtils; +import jexer.event.TMouseEvent; import jexer.io.ReadTimeoutException; import jexer.io.TimeoutInputStream; import static jexer.TKeypress.*; @@ -477,6 +479,17 @@ public class ECMA48 implements Runnable { */ private int textHeight = 20; + /** + * The last used height of a character cell in pixels, only used for + * full-width chars. + */ + private int lastTextHeight = -1; + + /** + * The glyph drawer for full-width chars. + */ + GlyphMaker glyphMaker = null; + /** * DECSC/DECRC save/restore a subset of the total state. This class * encapsulates those specific flags/modes. @@ -1377,6 +1390,35 @@ public class ECMA48 implements Runnable { private void printCharacter(final char ch) { int rightMargin = this.rightMargin; + if (StringUtils.width(ch) == 2) { + // This is a full-width character. Save two spaces, and then + // draw the character as two image halves. + int x0 = currentState.cursorX; + int y0 = currentState.cursorY; + printCharacter(' '); + printCharacter(' '); + if ((currentState.cursorX == x0 + 2) + && (currentState.cursorY == y0) + ) { + // We can draw both halves of the character. + drawHalves(x0, y0, x0 + 1, y0, ch); + } else if ((currentState.cursorX == x0 + 1) + && (currentState.cursorY == y0) + ) { + // VT100 line wrap behavior: we should be at the right + // margin. We can draw both halves of the character. + drawHalves(x0, y0, x0 + 1, y0, ch); + } else { + // The character splits across the line. Draw the entire + // character on the new line, giving one more space for it. + x0 = currentState.cursorX - 1; + y0 = currentState.cursorY; + printCharacter(' '); + drawHalves(x0, y0, x0 + 1, y0, ch); + } + return; + } + // Check if we have double-width, and if so chop at 40/66 instead of // 80/132 if (display.get(currentState.cursorY).isDoubleWidth()) { @@ -1427,19 +1469,22 @@ public class ECMA48 implements Runnable { CellAttributes newCellAttributes = (CellAttributes) newCell; newCellAttributes.setTo(currentState.attr); DisplayLine line = display.get(currentState.cursorY); - // Insert mode special case - if (insertMode == true) { - line.insert(currentState.cursorX, newCell); - } else { - // Replace an existing character - line.replace(currentState.cursorX, newCell); - } - // Increment horizontal - if (wrapLineFlag == false) { - currentState.cursorX++; - if (currentState.cursorX > rightMargin) { - currentState.cursorX--; + if (StringUtils.width(ch) == 1) { + // Insert mode special case + if (insertMode == true) { + line.insert(currentState.cursorX, newCell); + } else { + // Replace an existing character + line.replace(currentState.cursorX, newCell); + } + + // Increment horizontal + if (wrapLineFlag == false) { + currentState.cursorX++; + if (currentState.cursorX > rightMargin) { + currentState.cursorX--; + } } } } @@ -6806,4 +6851,45 @@ public class ECMA48 implements Runnable { } + /** + * 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 char ch) { + + // System.err.println("drawHalves(): " + Integer.toHexString(ch)); + + if (lastTextHeight != textHeight) { + glyphMaker = GlyphMaker.getInstance(textHeight); + lastTextHeight = textHeight; + } + + Cell cell = new Cell(ch); + cell.setAttr(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(); + left.setTo(cell); + left.setImage(leftImage); + left.setWidth(Cell.Width.LEFT); + display.get(leftY).replace(leftX, left); + + Cell right = new Cell(); + right.setTo(cell); + right.setImage(rightImage); + right.setWidth(Cell.Width.RIGHT); + display.get(rightY).replace(rightX, right); + } + } -- 2.27.0