/* File: GrannyTextRenderer.java * Copyright (C) 2002-2003 The University of Iowa * Created by: Jeremy Faden * Jessica Swanner * Edward E. West * * This file is part of the das2 library. * * das2 is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA */ package org.das2.util; import java.awt.Color; import java.awt.Component; import java.awt.Font; import java.awt.FontMetrics; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Rectangle; import java.awt.RenderingHints; import java.awt.geom.Line2D; import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.Stack; import java.util.logging.Level; import java.util.logging.Logger; /** * Utility class for rendering "Granny" strings, which use the codes * identified by Grandle and Nystrom in their 1980 paper to provide * rich formatting such as new lines and superscripts. This has been * extended significantly to include some html support, and extensions * which paint arbitrary graphics. * Granny are strings like "E=mc!e2" where the "!e" indicates the pen should be * moved to the exponent position before drawing. This supports sequences * including:
 * !A  shift up one half line
 * !B  shift down one half line  (e.g.  !A3!n-!B4!n is 3/4).
 * !C  newline 
 * !D  subscript 0.62 of old font size.
 * !U  superscript of 0.62 of old font size.
 * !E  superscript 0.44 of old font size.
 * !I  subscript 0.44 of old font size.
 * !N  return to the original font size.
 * !R  restore position to last saved position
 * !S  save the current position.
 * !K  reduce the font size. (Not in IDL's set.)
 * !!  the exclamation point (!)
 * !(ext;args) where ext can be:
 * !(color;saddleBrown)  switch to color.
 * !(painter;codeId;codeArg1)  Plug-in Java code for painting regions.
 * !(bold) switch to bold
 * !(italic) switch to italic
 * !(unbold) switch off bold by switching to plain
 * !(unitalic) switch off italic by switching to plain
 * !(underline) switch to underline
 * !(ununderline) switch to underline
 * 
* For Greek and math symbols, Unicode characters should be * used like so: ☎ (☎ phone symbol), or symbols like Ω and ω * * The GrannyTextRenderer object is created and then the method * setString is called and layout is performed, in Jython: * *
 * def paint(g):
 *    gtr= GrannyTextRenderer()
 *    gtr.setString( g, 'E=mc!e2' )
 *    gtr.draw( g, 0, g.getFont().getHeight() )
 * 
* * * @see https://github.com/das-developers/das2java/wiki/Granny-Text-Strings * @see java.awt.font.TextLayout, which may do some of the same things. * @see org.das2.graph.GrannyTextLabeller * @author Edward West */ public class GrannyTextRenderer { public static final float LEFT_ALIGNMENT = 0.0f; public static final float CENTER_ALIGNMENT = 0.5f; public static final float RIGHT_ALIGNMENT = 1.0f; private Rectangle bounds=null; private ArrayList lineBounds; private String str; private String[] tokens; private float alignment = LEFT_ALIGNMENT; private static final Logger logger= LoggerManager.getLogger("das2.graph.text"); private boolean underline; public GrannyTextRenderer( ) { //setAlignment(CENTER_ALIGNMENT); } public static interface Painter { Rectangle2D paint( Graphics2D g, String[] args ); } private Map painters= new HashMap<>(); /** * add a painter for the grannyTextRenderer. This is done by associating * a Painter code with an id, and the id is used within the annotation string. * @param id id for the painter, where the id is found in the granny text string * @param p the painter code which draws on a graphics context. */ public void addPainter( String id, Painter p ) { painters.put( id, p ); } /** * remove the painter with the given id. * @param id id for the painter, where the id is found in the granny text string */ public void removePainter( String id ) { painters.remove(id); } /** * remove all the painters */ public void clearPainters() { painters.clear(); } /** * returns the bounds of the current string. The lower-left corner of * the first character will be roughly (0,0), to be compatible with * FontMetrics.getStringBounds(). * * @return a Rectangle indicating the text boundaries. * @throws IllegalArgumentException if the string has not been set. */ public Rectangle getBounds() { if ( lineBounds==null ) throw new IllegalArgumentException("string is not set"); Rectangle r= maybeInitBounds(); return new Rectangle( r ); // defensive copy } /** * return a rectangle backed by floating point numbers. * @return Rectangle2D.Double */ public Rectangle2D getBounds2D() { Rectangle r= getBounds(); Rectangle2D result= new Rectangle2D.Double( r.x, r.y, r.width, r.height ); return result; } private Rectangle calculateBounds( ArrayList llineBounds ) { Rectangle lbounds; if ( lineBounds==null ) { logger.fine("lineBounds not set"); return new Rectangle( 0,-12,12,12 ); } else { if ( llineBounds.size()>0 && llineBounds.get(0)!=null ) { lbounds = new Rectangle((Rectangle)llineBounds.get(0)); for (int i = 1; i < llineBounds.size(); i++) { lbounds.add(llineBounds.get(i)); } } else { logger.fine("lineBounds size is 0"); lbounds = new Rectangle( 0,-12,12,12 ); } } return lbounds; } /** * return the current bounds, possibly initializing. * @return */ private Rectangle maybeInitBounds() { Rectangle bounds= this.bounds; if (bounds == null) { bounds= calculateBounds(lineBounds); } return bounds; } /** * returns the width of the bounding box, in pixels. * @return the width of the bounding box, in pixels. * @throws IllegalArgumentException if the string has not been set. */ public double getWidth() { if ( lineBounds==null ) throw new IllegalArgumentException("string is not set"); maybeInitBounds(); return bounds.getWidth(); } /** * returns the width in pixels of the first line. * @return the width in pixels of the first line. * @throws IllegalArgumentException if the string has not been set. * */ public double getLineOneWidth() { if ( lineBounds==null ) throw new IllegalArgumentException("string is not set"); return getLineWidth(1); } /** * returns the calculated width each line. * @param lineNumber the index of the line, starting with 1. * @return the width of the bounding box, in pixels. * @throws IndexOutOfBoundsException if no such line exists. */ private double getLineWidth(int lineNumber) { if ( lineBounds==null ) throw new IllegalArgumentException("string is not set"); return ((Rectangle)lineBounds.get(lineNumber - 1)).getWidth(); } /** * returns the hieght of the calculated bounding box. * @return the height of the bounding box, in pixels. * @throws IllegalArgumentException if the string has not been set. */ public double getHeight() { if ( lineBounds==null ) throw new IllegalArgumentException("string is not set"); maybeInitBounds(); return bounds.getHeight(); } /** * return the amount that the bounding box will go above the baseline. * This is also the height of the first line. * @return the amount that the bounding box will go above the baseline. * @throws IllegalArgumentException if the string has not been set. */ public double getAscent() { if ( lineBounds==null ) throw new IllegalArgumentException("string is not set"); if ( lineBounds.isEmpty() ) throw new IllegalArgumentException("getAscent called but string has not been drawn"); return -1*((Rectangle)lineBounds.get(0)).getY(); } /** * return the amount that the bounding box will go below the baseline. * @return the amount that the bounding box will go below the baseline. * @throws IllegalArgumentException if the string has not been set. */ public double getDescent() { if ( lineBounds==null ) throw new IllegalArgumentException("string is not set"); maybeInitBounds(); return bounds.getHeight() + bounds.getY(); } /** * reset the current string for the GTR to draw, calculating the boundaries * of the string. For greek and math symbols, unicode characters should be * used. (See www.unicode.org). See the documentation for this class * for a description of symbols. * @deprecated use setString( Graphics g, String str ) instead. * @param c the component which will provide the graphics. * @param str the granny string, such as "E=mc!e2" */ public void setString( Component c, String str ) { bounds = null; lineBounds = new ArrayList(); this.str = Entities.decodeEntities(str); this.tokens = buildTokenArray(this.str); this.draw( c.getGraphics(), c.getFont(), 0f, 0f, false ); } /** * reset the current string for the GTR to draw, calculating the boundaries * of the string. For greek and math symbols, unicode characters should be * used. (See www.unicode.org). * * @param g the graphics context which will supply the FontMetrics. * @param str the granny string, such as "E=mc!e2" */ public void setString( Graphics g, String str) { bounds = null; lineBounds = new ArrayList(); this.str = Entities.decodeEntities(str); this.tokens = buildTokenArray(this.str); this.draw( g, g.getFont(), 0f, 0f, false ); if ( lineBounds.isEmpty() || lineBounds.get(0)==null ) { System.err.println("rte_0015749633"); } } /** * reset the current string for the GTR to draw, calculating the boundaries * of the string. For greek and math symbols, unicode characters should be * used. (See www.unicode.org). * * @param font the font. This should be consistent * with the Font used when drawing. * @param label the granny string, such as "E=mc!e2" */ public void setString( Font font, String label) { bounds = null; lineBounds = new ArrayList(); this.str = Entities.decodeEntities(label); this.tokens = buildTokenArray(this.str); this.draw( null, font, 0f, 0f, false ); } /** * return the string. * @return */ public String getString() { return this.str; } /** * returns the current alignment, by default LEFT_ALIGNMENT. * @return the current alignment. */ public float getAlignment() { return alignment; } /** * set the alignment for rendering, one of LEFT_ALIGNMENT CENTER_ALIGNMENT or RIGHT_ALIGNMENT. * @param a the alignment, one of LEFT_ALIGNMENT CENTER_ALIGNMENT or RIGHT_ALIGNMENT. */ public void setAlignment( float a) { if (a != LEFT_ALIGNMENT && a != CENTER_ALIGNMENT && a != RIGHT_ALIGNMENT) { throw new IllegalArgumentException("alignment should 0., 0.5, or 1.0"); } alignment = a; } /** * if true, then draw a contrasting color around the label. */ private boolean glow = false; public static final String PROP_GLOW = "glow"; public boolean isGlow() { return glow; } public void setGlow(boolean glow) { this.glow = glow; } /** * draw the current string. Note the first line will be above iy, and following lines will * be below iy. This is to be consistent with Graphics2D.drawString. * * @param ig Graphic object to use to render the text. * @param ix The x position of the first character of text. * @param iy The y position of the baseline of the first line of text. */ public void draw( Graphics ig, float ix, float iy ) { if ( glow ) { Color color0= ig.getColor(); Color backColor0= color0.getRed()<128 ? Color.WHITE : Color.BLACK; ig.setColor(backColor0); this.draw( ig, ig.getFont(), ix-1, iy, true); this.draw( ig, ig.getFont(), ix+1, iy, true); this.draw( ig, ig.getFont(), ix, iy-1, true); this.draw( ig, ig.getFont(), ix, iy+1, true); ig.setColor(color0); } this.draw( ig, ig.getFont(), ix, iy, true); } private static void drawText( Graphics ig, boolean draw, float y, boolean underline, String strl, Font font, TextPosition current, Rectangle boundsl ) { // boolean canDoIt= true; // for ( int i=0; inull if * draw is false. * @param ix The x position of the first character of text. * @param iy The y position of the baseline of the first line of text. * @param c The Component that the String will be rendered in. * This can be null if draw is true * @param draw A boolean flag indicating whether or not the drawing code should be executed. * @throws NullPointerException if ig is null AND draw is true. * @throws NullPointerException if c is null AND draw is false. */ private void draw(Graphics ig, Font baseFont, float ix, float iy, boolean draw ) { logger.entering( "GrannyTextRenderer", "draw", new Object[] { baseFont, ix, iy, draw, str } ); Font activeFont= baseFont; boolean debug= false; if ( debug && !draw && this.tokens.length>1 ) { logger.info("draw debug"); } ArrayList llineBounds= new ArrayList<>(); // work on a local copy Graphics2D g = null; Rectangle boundsl = null; // the current line's bounds. if (draw) { g = (Graphics2D)ig.create(); RenderingHints hints = new RenderingHints(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); hints.put(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); g.setRenderingHints(hints); } Color color0; if ( g!=null ) { color0= g.getColor(); } else { color0= Color.BLACK; } final int NONE = 0; final int SUB_U = 1; final int SUB_D = 2; final int SUB_L = 3; final int EXP = 4; final int IND = 5; final int LOWCAPS= 10; // not in IDL's set final int SUB_A = 11; final int SUB_B = 12; if ( ig==null ) { ig= getHeadlessGraphicsContext(); } if ( baseFont==null ) { baseFont= Font.decode("sans-10"); } int lineNum=1; TextPosition current = new TextPosition(NONE, NONE, ix, iy); if (draw) { if (alignment == CENTER_ALIGNMENT) { current.x += (getWidth() - getLineOneWidth()) / 2.0; } else if (alignment == RIGHT_ALIGNMENT) { current.x += (getWidth() - getLineOneWidth()); } } if (!draw) { boundsl= new Rectangle((int)ix,(int)iy,0,0); } Stack saveStack = new Stack(); for (String strl : tokens) { if ( !strl.equals("!!") && strl.charAt(0) == '!') { if ( strl.length()==1 ) break; switch (strl.charAt(1)) { case 'A': case 'a': current.sub= SUB_A; current.ei = NONE; break; case 'B': case 'b': current.sub= SUB_B; current.ei = NONE; break; case 'C': case 'c': lineNum++; current.sub = NONE; current.ei = NONE; current.x = ix; current.y += baseFont.getSize2D(); if (draw) { g.setFont(baseFont); if (alignment == CENTER_ALIGNMENT) { current.x += (getWidth() - getLineWidth(lineNum)) / 2.0; } else if (alignment == RIGHT_ALIGNMENT) { current.x += (getWidth() - getLineWidth(lineNum)); } } saveStack.clear(); if (!draw) { llineBounds.add(boundsl); boundsl = new Rectangle((int)current.x, (int)current.y, 0, 0); } break; case 'U': case 'u': current.sub = SUB_U; current.ei = NONE; break; case 'D': case 'd': current.sub = SUB_D; current.ei = NONE; break; case 'L': case 'l': current.sub = SUB_L; current.ei = NONE; break; case 'K': case 'k': current.ei = LOWCAPS; break; case 'E': case 'e': current.ei = EXP; break; case 'I': case 'i': current.ei = IND; break; case 'S': case 's': saveStack.push(new TextPosition(current)); break; case 'R': case 'r': if ( !saveStack.empty() ) { if (saveStack.peek() == null) return; current.copy((TextPosition)saveStack.pop()); } else { logger.log(Level.WARNING, "saveStack was empty: missing !s from: {0}", this.str); } break; case 'N': case 'n': current.sub = NONE; current.ei = NONE; if ( draw && g.getColor()!=color0 ) g.setColor(color0); break; case '(': // !(color;saddleBrown) int i= strl.indexOf(";"); if ( i==-1 ) i= strl.indexOf(")"); String command= strl.substring(2,i); if ( command.indexOf(',')>-1 ) { logger.log(Level.INFO, "command cannot contain comma: {0}", command); break; } if ( command.equals("color") ) { if ( draw ) { String scolor= i==(strl.length()-1) ? "" : strl.substring(i+1,strl.length()-1); if ( scolor.length()==0 ) { g.setColor(color0); } else { try { Color c= ColorUtil.decodeColor(scolor); g.setColor(c); } catch ( IllegalArgumentException ex ) { logger.log(Level.INFO, "could not decode color: {0}", scolor); } } } } else if ( command.equals("painter") ) { String p= i==(strl.length()-1) ? "" : strl.substring(i+1,strl.length()-1); String[] pp= p.split("\\;"); Painter painter= painters.get(pp[0]); if ( painter==null ) { logger.log(Level.INFO, "no such painter: {0}", pp[0] ); } else { String[] args= Arrays.copyOfRange( pp, 1, pp.length ); Rectangle2D b1; Graphics2D g4; if ( draw ) { g4= (Graphics2D) g.create( (int)current.x, (int)current.y, 100, 100 ); g4.setClip(null); } else { g4= (Graphics2D) ig.create( (int)current.x, (int)current.y, 100, 100 ); } g4.setFont(activeFont); try { b1= painter.paint( g4, args ); g4.dispose(); if ( b1==null ) { logger.warning("width not reported, using 16px"); b1= new Rectangle2D.Float(0,0,16,16); } } catch ( Exception e ) { e.printStackTrace(); b1= new Rectangle2D.Float(0,0,16,16); } if ( !draw ) { boundsl.add( new Rectangle2D.Float( current.x+(float)b1.getX(), current.y+(float)b1.getY(), (float)b1.getWidth(), (float)b1.getHeight() ) ); } current.x+= b1.getWidth(); } } else if ( command.equals("bold") ) { if ( activeFont.isItalic() ) { activeFont = activeFont.deriveFont(Font.ITALIC | Font.BOLD ); } else { activeFont = activeFont.deriveFont(Font.BOLD); } } else if ( command.equals("unbold") ) { if ( activeFont.isItalic() ) { activeFont = activeFont.deriveFont(Font.ITALIC); } else { activeFont = activeFont.deriveFont(Font.PLAIN); } } else if ( command.equals("italic") ) { if ( activeFont.isBold() ) { activeFont = activeFont.deriveFont(Font.ITALIC | Font.BOLD ); } else { activeFont = activeFont.deriveFont(Font.ITALIC); } } else if ( command.equals("unitalic") ) { if ( activeFont.isBold() ) { activeFont = activeFont.deriveFont(Font.BOLD); } else { activeFont = activeFont.deriveFont(Font.PLAIN); } } else if ( command.equals("underline") ) { underline= true; } else if ( command.equals("ununderline") ) { underline= false; } else { logger.log(Level.INFO, "unrecognized command: {0}", command); } break; case '!': break; default:break; } } else { Font font = activeFont; float size = activeFont.getSize2D(); float y = current.y; switch (current.sub) { case SUB_U: font = activeFont.deriveFont(size * 0.62f); y = y - 0.38f * size; size = size * 0.62f; break; case SUB_D: font = activeFont.deriveFont(size * 0.62f); y = y + 0.31f * size; size = size * 0.62f; break; case SUB_L: font = activeFont.deriveFont(size * 0.62f); y = y + 0.62f * size; size = size * 0.62f; break; case SUB_A: y= current.y - size/2; break; case SUB_B: y= current.y + size/2; break; default:break; } switch (current.ei) { case EXP: font = font.deriveFont(size * 0.44f); y = y - 0.56f * size; break; case IND: font = font.deriveFont(size * 0.44f); y = y + 0.22f * size; break; case LOWCAPS: font= font.deriveFont(size * 0.80f ); break; default:break; } if ( strl.equals("!!") ) strl= "!"; if ( draw ) { drawText( g, draw, y, underline, strl, font, current, boundsl); } else { drawText( ig, draw, y, underline, strl, font, current, boundsl); } } } // for (String strl : tokens) {// for (String strl : tokens) { if (!draw) { llineBounds.add(boundsl); this.lineBounds= llineBounds; this.bounds= calculateBounds(llineBounds); } if ( debug && draw && this.bounds!=null ) { Rectangle r = new Rectangle(this.bounds); r.translate( (int)current.x-r.width,(int)current.y ); g.setColor( new Color( 150, 100, 100, 100 ) ); g.fill( r ); } if (draw) { g.dispose(); } logger.exiting( "GrannyTextRenderer", "draw" ); } private static String[] buildTokenArray(String str) { java.util.List vector = new ArrayList(); int begin; int end = 0; str= str.replaceAll("\\","!c"); str= str.replaceAll("\\","!u"); str= str.replaceAll("\\","!n"); str= str.replaceAll("\\","!d"); str= str.replaceAll("\\","!n"); str= str.replaceAll("\\","!(bold)"); str= str.replaceAll("\\","!(unbold)"); str= str.replaceAll("\\","!(italic)"); str= str.replaceAll("\\","!(unitalic)"); str= str.replaceAll("\\","!(underline)"); str= str.replaceAll("\\","!(ununderline)"); while(end < str.length()) { begin = end; if (str.charAt(begin) == '!') { if ( str.length()>begin+2 ) { char p= str.charAt(begin+1); if ( p=='(') { int i= str.indexOf(')',begin+2); if ( i==-1 ) { logger.info("no closing paren found."); end= begin+2; } else { end= i+1; } } else { end = begin + 2; } } else { end = begin + 2; } if ( end>=str.length() ) end= str.length(); } else { end = str.indexOf('!', begin); if (end == -1) end = str.length(); } vector.add(str.substring(begin, end)); } String[] list= vector.toArray( new String[vector.size()] ); return list; } @Override public String toString() { maybeInitBounds(); StringBuilder buffer = new StringBuilder(getClass().getName()); buffer.append(": ").append(str).append(", "); buffer.append("bounds: ").append(bounds).append(", ").append("lineBounds:").append(lineBounds).append(", "); return buffer.toString(); } /** * count the number of lines in the string, breaking on "!c" or "<br>", ignoring empty lines at the beginning. * @param s the string * @return the number of lines */ public static int lineCount( String s ) { String[] ss= s.split("(\\!c|\\!C|\\)"); int emptyLines=0; while ( emptyLines