diff --git a/jme3-core/src/main/java/com/jme3/font/BitmapFont.java b/jme3-core/src/main/java/com/jme3/font/BitmapFont.java index 40a5c43f65..7b952b330e 100644 --- a/jme3-core/src/main/java/com/jme3/font/BitmapFont.java +++ b/jme3-core/src/main/java/com/jme3/font/BitmapFont.java @@ -33,6 +33,7 @@ import com.jme3.export.*; import com.jme3.material.Material; + import java.io.IOException; /** @@ -88,6 +89,24 @@ public enum VAlign { private BitmapCharacterSet charSet; private Material[] pages; + private boolean rightToLeft = false; + // For cursive bitmap fonts in which letter shape is determined by the adjacent glyphs. + private GlyphParser glyphParser; + + /** + * @return true, if this is a right-to-left font, otherwise it will return false. + */ + public boolean isRightToLeft() { + return rightToLeft; + } + + /** + * Specify if this is a right-to-left font. By default it is set to false. + * This can be "overwritten" in the BitmapText constructor. + */ + public void setRightToLeft(boolean rightToLeft) { + this.rightToLeft = rightToLeft; + } public BitmapFont() { } @@ -123,7 +142,23 @@ public int getPageSize() { public BitmapCharacterSet getCharSet() { return charSet; } - + + /** + * For cursive fonts a GlyphParser needs to be specified which is used + * to determine glyph shape by the adjacent glyphs. If nothing is set, + * all glyphs will be rendered isolated. + */ + public void setGlyphParser(GlyphParser glyphParser) { + this.glyphParser = glyphParser; + } + + /** + * @return The GlyphParser set on the font, or null if it has no glyph parser. + */ + public GlyphParser getGlyphParser() { + return glyphParser; + } + /** * Gets the line height of a StringBlock. * @@ -156,6 +191,8 @@ public void write(JmeExporter ex) throws IOException { OutputCapsule oc = ex.getCapsule(this); oc.write(charSet, "charSet", null); oc.write(pages, "pages", null); + oc.write(rightToLeft, "rightToLeft", false); + oc.write(glyphParser, "glyphParser", null); } @Override @@ -165,6 +202,8 @@ public void read(JmeImporter im) throws IOException { Savable[] pagesSavable = ic.readSavableArray("pages", null); pages = new Material[pagesSavable.length]; System.arraycopy(pagesSavable, 0, pages, 0, pages.length); + rightToLeft = ic.readBoolean("rightToLeft", false); + glyphParser = (GlyphParser) ic.readSavable("glyphParser", null); } public float getLineWidth(CharSequence text){ @@ -208,8 +247,10 @@ public float getLineWidth(CharSequence text){ boolean firstCharOfLine = true; // float sizeScale = (float) block.getSize() / charSet.getRenderedSize(); float sizeScale = 1f; - for (int i = 0; i < text.length(); i++){ - char theChar = text.charAt(i); + CharSequence characters = glyphParser != null ? glyphParser.parse(text) : text; + + for (int i = 0; i < characters.length(); i++){ + char theChar = characters.charAt(i); if (theChar == '\n'){ maxLineWidth = Math.max(maxLineWidth, lineWidth); lineWidth = 0f; @@ -217,38 +258,50 @@ public float getLineWidth(CharSequence text){ continue; } BitmapCharacter c = charSet.getCharacter(theChar); - if (c != null){ - if (theChar == '\\' && i scale it with Font.getPreferredSize()/BitMaptext.getSize() return letters.getTotalWidth(); } diff --git a/jme3-core/src/main/java/com/jme3/font/GlyphParser.java b/jme3-core/src/main/java/com/jme3/font/GlyphParser.java new file mode 100644 index 0000000000..56e1443008 --- /dev/null +++ b/jme3-core/src/main/java/com/jme3/font/GlyphParser.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2009-2021 jMonkeyEngine + * 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 'jMonkeyEngine' 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 OWNER 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.jme3.font; + +import com.jme3.export.Savable; + +/** + * Used for selecting character shape in cursive bitmap text. In cursive scripts, + * the appearance of a letter changes depending on its position: + * isolated, initial (joined on the left), medial (joined on both sides) + * and final (joined on the right) of a word. + * + * For an example implementation see: https://github.com/Ali-RS/JME-PersianGlyphParser + * + * @author Ali-RS + */ +public interface GlyphParser extends Savable { + + public CharSequence parse(CharSequence text); + +} \ No newline at end of file diff --git a/jme3-core/src/main/java/com/jme3/font/LetterQuad.java b/jme3-core/src/main/java/com/jme3/font/LetterQuad.java index 4faa475dfe..5ac7391c31 100644 --- a/jme3-core/src/main/java/com/jme3/font/LetterQuad.java +++ b/jme3-core/src/main/java/com/jme3/font/LetterQuad.java @@ -170,7 +170,12 @@ boolean isInvalid(StringBlock block, float gap) { if (bound == null) { return false; } - return x0 > 0 && bound.x+bound.width-gap < getX1(); + if (isRightToLeft()) { + return x0 <0 && x0 0 && bound.x+bound.width-gap < getX1(); + } } void clip(StringBlock block) { @@ -182,6 +187,7 @@ void clip(StringBlock block) { // to the string block float x1 = Math.min(bound.x + bound.width, x0 + width); float newWidth = x1 - x0; + if (isRightToLeft()) newWidth = x1; // only the available space to the left if( newWidth == width ) return; @@ -195,13 +201,12 @@ float getX0() { } float getX1() { - return x0+width; + return x0 + width; } - float getNextX() { - return x0+xAdvance; + return rightToLeft ? x0 - xAdvance : x0 + xAdvance; } - + float getNextLine() { return lineY+LINE_DIR*font.getCharSet().getLineHeight() * sizeScale; } @@ -314,6 +319,9 @@ void update(StringBlock block) { if (isHead()) { x0 = getBound(block).x; + if (isRightToLeft() && getBound(block) != UNBOUNDED) { + x0 += getBound(block).width; + } y0 = lineY; width = 0; height = 0; @@ -333,6 +341,7 @@ void update(StringBlock block) { xAdvance = width; } else if (bitmapChar == null) { x0 = getPrevious().getX1(); + if (rightToLeft) x0 = getPrevious().getX0(); y0 = lineY; width = 0; height = 0; @@ -347,30 +356,56 @@ void update(StringBlock block) { float kernAmount = 0f; if (previous.isHead() || previous.eol) { - x0 = bound.x; - - // The first letter quad will be drawn right at the first - // position... but it does not offset by the characters offset - // amount. This means that we've potentially accumulated extra - // pixels, and the next letter won't get drawn far enough unless - // we add this offset back into xAdvance, by subtracting it. - // This is the same thing that's done below because we've - // technically baked the offset in just like below. It doesn't - // look like it at first glance, so I'm keeping it separate with - // this comment. - xAdvance -= xOffset * incrScale; - + if (rightToLeft) { + // In RTL text we advance toward left by the letter xAdvance. (subtract xAdvance) + // Note, positive offset will move the letter quad toward right and negative offset + // will move it toward left. + if (previous.isHead()) { + x0 = previous.getNextX() - xAdvance - xOffset * incrScale; + } else if (previous.eol) { + // For bounded bitmap text the first letter of a line is always + // on the right end of the textbox and for unbounded bitmap text + // we start from the x=0 and advance toward left. + x0 = getBound(block).x + (getBound(block) != UNBOUNDED ? getBound(block).width : 0) - xAdvance - xOffset * incrScale; + } + // Since x0 has xAdvance baked into it, we need to zero out xAdvance. + // Since x0 will have offset baked into it, we need to counteract that + // in xAdvance. The next x position will be (x0 - xAdvance). + xAdvance = -xOffset * incrScale; + } else { + x0 = bound.x; + + // The first letter quad will be drawn right at the first + // position, but it does not offset by the character's offset + // amount. This means that we've potentially accumulated extra + // pixels, and the next letter won't get drawn far enough unless + // we add this offset back into xAdvance, by subtracting it. + // This is the same thing that's done below, because we've + // technically baked the offset in just like below. It doesn't + // look like it at first glance, so I'm keeping it separate with + // this comment. + xAdvance -= xOffset * incrScale; + } } else { - x0 = previous.getNextX() + xOffset * incrScale; - - // Since x0 will have offset baked into it then we - // need to counteract that in xAdvance. This is better - // than removing it in getNextX() because we also need - // to take kerning into account below... which will also - // get baked in. - // Without this, getNextX() will return values too far to - // the left, for example. - xAdvance -= xOffset * incrScale; + if (isRightToLeft()) { + // For RTL text the xAdvance of the current letter is deducted, + // while for LTR text the advance of the letter before is added. + x0 = previous.getNextX() - xAdvance - xOffset * incrScale; + // Since x0 has xAdvance baked into it, we need to zero out xAdvance. + // Since x0 will have offset baked into it we need to counteract that + // in xAdvance. The next x position will be (x0 - xAdvance) + xAdvance = - xOffset * incrScale; + } else { + x0 = previous.getNextX() + xOffset * incrScale; + // Since x0 will have offset baked into it, we + // need to counteract that in xAdvance. This is better + // than removing it in getNextX() because we also need + // to take kerning into account below, which will also + // get baked in. + // Without this, getNextX() will return values too far to + // the left, for example. + xAdvance -= xOffset * incrScale; + } } y0 = lineY + LINE_DIR*yOffset; @@ -379,12 +414,11 @@ void update(StringBlock block) { if (lastChar != null && block.isKerning()) { kernAmount = lastChar.getKerning(c) * sizeScale; x0 += kernAmount * incrScale; - - // Need to unbake the kerning from xAdvance since it + // Need to unbake the kerning from xAdvance since it // is baked into x0... see above. //xAdvance -= kernAmount * incrScale; // No, kerning is an inter-character spacing and _does_ affect - // all subsequent cursor positions. + // all subsequent cursor positions. } } if (isEndOfLine()) { diff --git a/jme3-core/src/main/java/com/jme3/font/Letters.java b/jme3-core/src/main/java/com/jme3/font/Letters.java index 4529927507..426b238374 100644 --- a/jme3-core/src/main/java/com/jme3/font/Letters.java +++ b/jme3-core/src/main/java/com/jme3/font/Letters.java @@ -73,8 +73,13 @@ void setText(final String text) { current = head; if (text != null && plainText.length() > 0) { LetterQuad l = head; - for (int i = 0; i < plainText.length(); i++) { - l = l.addNextCharacter(plainText.charAt(i)); + CharSequence characters = plainText; + if (font.getGlyphParser() != null) { + characters = font.getGlyphParser().parse(plainText); + } + + for (int i = 0; i < characters.length(); i++) { + l = l.addNextCharacter(characters.charAt(i)); if (baseColor != null) { // Give the letter a default color if // one has been provided. @@ -114,7 +119,7 @@ void update() { while (!l.isTail()) { if (l.isInvalid()) { l.update(block); - + // Without a textblock the next line returns always false = no textwrap at all will be applied if (l.isInvalid(block)) { switch (block.getLineWrapMode()) { case Character: @@ -164,6 +169,7 @@ void update() { l.clip(block); // Clear the rest up to the next line feed. + // = for texts attached to a textblock all coming characters are cleared except a linefeed is explicitly used for( LetterQuad q = l.getNext(); !q.isTail() && !q.isLineFeed(); q = q.getNext() ) { q.setBitmapChar(null); q.update(block); @@ -186,44 +192,80 @@ void update() { } private void align() { + if (block.getTextBox() == null) { + // Without a textblock there is no alignment. + return; + + // For unbounded left-to-right texts the letters will simply be shown starting from + // x0 = 0 and advance toward right as line length is considered to be infinite. + // For unbounded right-to-left texts the letters will be shown starting from x0 = 0 + // (at the same position as left-to-right texts) but move toward the left from there. + } + final Align alignment = block.getAlignment(); final VAlign valignment = block.getVerticalAlignment(); - if (block.getTextBox() == null || (alignment == Align.Left && valignment == VAlign.Top)) - return; - LetterQuad cursor = tail.getPrevious(); - cursor.setEndOfLine(); final float width = block.getTextBox().width; final float height = block.getTextBox().height; float lineWidth = 0; float gapX = 0; float gapY = 0; + validateSize(); if (totalHeight < height) { // align vertically only for no overflow switch (valignment) { - case Top: - gapY = 0; - break; - case Center: - gapY = (height - totalHeight) * 0.5f; - break; - case Bottom: - gapY = height - totalHeight; - break; + case Top: + gapY = 0; + break; + case Center: + gapY = (height - totalHeight) * 0.5f; + break; + case Bottom: + gapY = height - totalHeight; + break; } } - while (!cursor.isHead()) { - if (cursor.isEndOfLine()) { - lineWidth = cursor.getX1()-block.getTextBox().x; - if (alignment == Align.Center) { - gapX = (width-lineWidth)/2; - } else if (alignment == Align.Right) { - gapX = width-lineWidth; - } else { - gapX = 0; + + if (font.isRightToLeft()) { + if ((alignment == Align.Right && valignment == VAlign.Top)) { + return; + } + LetterQuad cursor = tail.getPrevious(); + // Temporary set the flag, it will be reset when invalidated. + cursor.setEndOfLine(); + while (!cursor.isHead()) { + if (cursor.isEndOfLine()) { + if (alignment == Align.Left) { + gapX = block.getTextBox().x - cursor.getX0(); + } else if (alignment == Align.Center) { + gapX = (block.getTextBox().x - cursor.getX0()) / 2; + } else { + gapX = 0; + } } + cursor.setAlignment(gapX, gapY); + cursor = cursor.getPrevious(); + } + } else { // left-to-right + if (alignment == Align.Left && valignment == VAlign.Top) { + return; + } + LetterQuad cursor = tail.getPrevious(); + // Temporary set the flag, it will be reset when invalidated. + cursor.setEndOfLine(); + while (!cursor.isHead()) { + if (cursor.isEndOfLine()) { + lineWidth = cursor.getX1() - block.getTextBox().x; + if (alignment == Align.Center) { + gapX = (width - lineWidth) / 2; + } else if (alignment == Align.Right) { + gapX = width - lineWidth; + } else { + gapX = 0; + } + } + cursor.setAlignment(gapX, gapY); + cursor = cursor.getPrevious(); } - cursor.setAlignment(gapX, gapY); - cursor = cursor.getPrevious(); } } @@ -232,7 +274,7 @@ private void lineWrap(LetterQuad l) { return; l.getPrevious().setEndOfLine(); l.invalidate(); - l.update(block); // TODO: update from l + l.update(block); } float getCharacterX0() { @@ -319,10 +361,15 @@ float getTotalHeight() { } void validateSize() { + // also called from BitMaptext.getLineWidth() via getTotalWidth() if (totalWidth < 0) { LetterQuad l = head; while (!l.isTail()) { - totalWidth = Math.max(totalWidth, l.getX1()); + if (font.isRightToLeft()) { + totalWidth = Math.max(totalWidth, Math.abs(l.getX0())); + } else { + totalWidth = Math.max(totalWidth, l.getX1()); + } l = l.getNext(); } } diff --git a/jme3-examples/src/main/java/jme3test/gui/TestRtlBitmapText.java b/jme3-examples/src/main/java/jme3test/gui/TestRtlBitmapText.java index be8d85644a..0b9ae4bab9 100644 --- a/jme3-examples/src/main/java/jme3test/gui/TestRtlBitmapText.java +++ b/jme3-examples/src/main/java/jme3test/gui/TestRtlBitmapText.java @@ -32,18 +32,17 @@ package jme3test.gui; import com.jme3.app.SimpleApplication; -import com.jme3.font.BitmapFont; -import com.jme3.font.BitmapText; -import com.jme3.font.LineWrapMode; -import com.jme3.font.Rectangle; +import com.jme3.app.StatsAppState; +import com.jme3.font.*; /** * Test case for JME issue #1158: BitmapText right to left line wrapping not work */ public class TestRtlBitmapText extends SimpleApplication { - // A right to left text. - final private String text = ".text left to right test a is This"; + private String text = "This is a test right to left text."; + private BitmapFont fnt; + private BitmapText txt; public static void main(String[] args) { TestRtlBitmapText app = new TestRtlBitmapText(); @@ -52,14 +51,20 @@ public static void main(String[] args) { @Override public void simpleInitApp() { - BitmapFont fnt = assetManager.loadFont("Interface/Fonts/Default.fnt"); + float x = 400; + float y = 500; + getStateManager().detach(stateManager.getState(StatsAppState.class)); + fnt = assetManager.loadFont("Interface/Fonts/Default.fnt"); + fnt.setRightToLeft(true); + // A right to left BitmapText - BitmapText txt = new BitmapText(fnt, true); + txt = new BitmapText(fnt); txt.setBox(new Rectangle(0, 0, 150, 0)); txt.setLineWrapMode(LineWrapMode.Word); txt.setAlignment(BitmapFont.Align.Right); txt.setText(text); - txt.setLocalTranslation(cam.getWidth() / 2, cam.getHeight() / 2, 0); + + txt.setLocalTranslation(x, y, 0); guiNode.attachChild(txt); } }