changeset 30:7a16edc5b579

Added implementation for enso.graphics.textlayout.
author Atul Varma <varmaa@toolness.com>
date Sat, 23 Feb 2008 10:38:02 -0600
parents 47bcdb1de2e8
children 5e4c680f49a3
files enso/graphics/textlayout.py
diffstat 1 files changed, 603 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- a/enso/graphics/textlayout.py	Sat Feb 23 10:33:54 2008 -0600
+++ b/enso/graphics/textlayout.py	Sat Feb 23 10:38:02 2008 -0600
@@ -0,0 +1,603 @@
+# Copyright (c) 2008, Humanized, Inc.
+# All rights reserved.
+# 
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#    1. Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#
+#    2. 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.
+#
+#    3. Neither the name of Enso 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 Humanized, Inc. ``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 Humanized, Inc. 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.
+
+# ----------------------------------------------------------------------------
+#
+#   enso.graphics.textlayout
+#
+# ----------------------------------------------------------------------------
+
+"""
+    Module for text layout.
+
+    Text layout is accomplished by first laying-out the text one wants
+    to render, and then rendering it.  Layout entities are
+    heirarchically organied: the root layout element is the Document,
+    which is made up of Blocks; Blocks are made up of Lines; and Lines
+    are made up of Glyphs.
+
+    The parameters used by this text layout interface is loosely based
+    on CSS.  To fully understand this module's interface, consider
+    reading the introduction to 'CSS Pocket Reference', 2nd edition.
+
+    This system is fairly standard as far as text layout engines go;
+    to fully understand its implementation, you should first
+    understand the basics of font glyph conventions.  A great tutorial
+    on this can be found here:
+
+      http://freetype.sourceforge.net/freetype2/docs/glyphs/index.html
+"""
+
+# ----------------------------------------------------------------------------
+# The Document Element
+# ----------------------------------------------------------------------------
+
+class Document:
+    """
+    Encapsulates a contiguous two-dimensional area of text layout.
+    The Document is made up of Blocks, each of which corresponds to a
+    vertical section of text with its own alignment and margins (e.g.,
+    the <p> tag in HTML).
+    """
+    
+    def __init__( self, width, marginTop, marginBottom ):
+        """
+        Creates a Document with the given width and margins, all in
+        points.
+        """
+
+        # Standard style properties, taken directly from the
+        # constructor parameters.
+        self.width = width
+        self.marginTop = marginTop
+        self.marginBottom = marginBottom
+
+        # List of blocks in the document.
+        self.blocks = []
+
+        # Total height of the block, in points.
+        self.height = None
+        
+    def addBlock( self, block ):
+        """
+        Adds the given Block object to the document.
+        """
+        
+        self.blocks.append( block )
+
+    def layout( self ):
+        """
+        Lays out the Document; must always be caled before drawing the
+        document and after all the blocks have been added.
+        """
+        
+        blocksHeight = 0
+        for block in self.blocks:
+            block.layout()
+            blocksHeight += block.height
+        self.height = self.marginTop + blocksHeight + self.marginBottom
+
+    def draw( self, x, y, cairoContext ):
+        """
+        Draws the document with its top-left corner at the given
+        position (in points), using the given cairo context.
+        """
+        
+        y += self.marginTop
+        for block in self.blocks:
+            block.draw( x, y, cairoContext )
+            y += block.height
+
+
+# ----------------------------------------------------------------------------
+# The Block Element
+# ----------------------------------------------------------------------------
+
+class Block:
+    """
+    The Block element, which a Document is made of.  A Block consists
+    of individual lines, and cannot have any layout elements to its
+    sides.
+    """
+    
+    def __init__( self, width, lineHeight, marginTop, marginBottom, textAlign,
+                  maxLines, ellipsify ):
+        """
+        Creates a Block object with the given width, line height, and
+        margins, all in points.  Also sets the alignment of the text
+        block--this can be any one of 'left', right', 'center', or
+        'justify'.
+
+        If 'maxLines' is set, then the Block cannot exceed the given
+        number of lines in length.  If 'ellipsify' is set, then the
+        text is truncated with an ellipsis character if it exceeds
+        that number of lines; otherwise, if the maximum number of
+        lines is exceeded, a MaxLinesExceededError is thrown.
+        """
+
+        # Standard style properties, taken directly from the
+        # constructor parameters.
+        self.lineHeight = lineHeight
+        self.marginTop = marginTop
+        self.marginBottom = marginBottom
+        self.width = width
+        self.textAlign = textAlign
+        self.maxLines = maxLines
+        self.ellipsify = ellipsify
+
+        # Temporary list of glyphs that need to be laid out into
+        # lines.
+        self.__glyphs = []
+
+        # List of lines in the block.
+        self.lines = []
+
+        # Glyph that will be used as an ellipsis character if
+        # necessary.
+        self.ellipsisGlyph = None
+
+        # Total height of the block, in points.
+        self.height = None
+
+    def setEllipsisGlyph( self, ellipsisGlyph ):
+        """
+        Sets the ellipsis glyph for the block; this is the glyph
+        inserted at the end of the final line of a block if maxLines
+        has been exceeded and ellipsify is set.
+        """
+        
+        self.ellipsisGlyph = ellipsisGlyph
+
+    def addGlyphs( self, glyphs ):
+        """
+        Adds the given glyphs to the block.
+        """
+        
+        self.__glyphs.extend( glyphs )
+
+    def __addLine( self, line, isPartialLine = False ):
+        """
+        Private method that adds the given line to the block.
+        'isPartialLine' should be set to true if the line being added
+        is not full--i.e., if the line doesn't have enough characters
+        on it that it needs to be word-wrapped.
+        """
+        
+        if isPartialLine and self.textAlign == "justify":
+            # A partial line (e.g., the last line) of justified text
+            # shouldn't be justified (or else it'll be "force
+            # justify".
+            alignment = "left"
+        else:
+            alignment = self.textAlign
+        line.layout( alignment, self.width, self.lineHeight )
+        self.lines.append( line )
+
+    def layout( self ):
+        """
+        Lays out the block. This method should be called before the
+        block is drawn, yet after all glyphs have been added to the
+        block.
+        """
+        
+        currLineLength = 0
+        currWordStartIndex = 0
+        currWordLength = 0
+        currLine = Line()
+        for i in range( len(self.__glyphs) ):
+            assert( currLineLength >= currWordLength )
+
+            glyph = self.__glyphs[i]
+            advance = glyph.fontGlyph.advance
+
+            if currLineLength + advance > self.width:
+                # If we don't have *any* characters on this line yet,
+                # that means the glyph advance is greater than this
+                # block's width--we're in big trouble!
+                if currLineLength == 0:
+                    raise GlyphWiderThanBlockError(glyph)
+
+                # Time to make a new line.
+
+                if len( self.lines ) == self.maxLines-1:
+                    # We've hit the max # of lines!
+                    if not self.ellipsify:
+                        raise MaxLinesExceededError()
+                    else:
+                        # We'll put an ellipsis at the end of this
+                        # line and then break out of this loop,
+                        # ignoring the rest of the glyphs.
+                        currLine.addGlyphs(
+                            self.__glyphs[currWordStartIndex:i]
+                            )
+                        currWordLength = 0
+                        currLine.ellipsify( self.ellipsisGlyph,
+                                            self.width )
+                        break
+
+                # Determine whether our current line has a word in it.
+                currLineHasWord = (currLineLength != currWordLength)
+
+                # If our current line has no words in it, just add
+                # what we've got so far (not including the glyph we're
+                # looking at) and count it as a "word".
+                
+                # Alternatively, if this character is whitespace, then
+                # we're at the end of a word; we'll effectively
+                # replace the whitespace with a newline.
+                if not currLineHasWord or glyph.isWhitespace:
+                    currLine.addGlyphs(
+                        self.__glyphs[currWordStartIndex:i]
+                        )
+
+                    # If the current character we're looking at is
+                    # whitespace, pretend it doesn't exist because
+                    # we're at the end of a line.
+                    if glyph.isWhitespace:
+                        currWordStartIndex = i+1
+                        currWordLength = 0
+                    else:
+                        currWordStartIndex = i
+                        currWordLength = advance
+                else:
+                    # This character is part of a word.
+                    currWordLength += advance
+
+                # Now, create a new line.
+                self.__addLine( currLine )
+                currLineLength = currWordLength
+                currLine = Line()
+            elif glyph.isWhitespace:
+                # We've reached the end of our word, and the beginning
+                # of another.  Add this word, including this
+                # whitespace character, to the current line.
+                currLine.addGlyphs(
+                    self.__glyphs[currWordStartIndex:i+1]
+                    )
+                currLineLength += advance
+                currWordStartIndex = i+1
+                currWordLength = 0
+            else:
+                # We're still building a word.
+                currLineLength += advance
+                currWordLength += advance
+
+            assert( currLineLength >= currWordLength )
+
+        assert( currLineLength >= currWordLength )
+
+        # Now that we're done looking through all the glyphs, we can
+        # safely add the last remaining word to the current line.
+        if currWordLength > 0:
+            currLine.addGlyphs(
+                self.__glyphs[currWordStartIndex:]
+                )
+
+        # If our current (i.e., last) line has anything on it, we're
+        # going to add it to the block.
+        if currLineLength > 0:
+            self.__addLine( currLine, isPartialLine = True )
+
+        self.__glyphs = None
+        self.height = self.marginTop + \
+                      self.lineHeight * len(self.lines) + \
+                      self.marginBottom
+
+    def draw( self, x, y, cairoContext ):
+        """
+        Draws the block with its upper-left corner at the given
+        coordinates (in points), using the given cairo context.
+        """
+        
+        for line in self.lines:
+            line.draw( x, y, cairoContext )
+            y += self.lineHeight
+
+
+class GlyphWiderThanBlockError( Exception ):
+    """
+    Exception raised when a glyph is wider than a block and therefore
+    can't be added to the block.
+    """
+
+    pass
+
+
+class MaxLinesExceededError( Exception ):
+    """
+    Exception thrown by a Block object when the maximum number of
+    lines for the block has been exceeded.
+    """
+    
+    pass
+
+
+# ----------------------------------------------------------------------------
+# The Line Element
+# ----------------------------------------------------------------------------
+
+class Line:
+    """
+    Encapsulates a line, made up of glyphs.
+
+    Note that some of the documentation for this class uses
+    terminology taken from Cascading Style Sheets; in particular, see
+    'CSS Pocket Reference', 2nd edition, pgs. 12-13.
+    """
+    
+    def __init__( self ):
+        """
+        Creates an empty line.
+        """
+        
+        self.glyphs = []
+
+        # Current cursor position at which next glyph will be placed
+        # on line.
+        self.__cursorPos = 0.0
+
+        # X-offset for alignment (left, right, centered, etc.).
+        self.__alignOfs = 0.0
+
+        # Offset per space for justified text.
+        self.__ofsPerSpace = 0.0
+
+        # Ascent of the line above the baseline, in points.
+        self.ascent = None
+
+        # Descent of the line below the baseline, in points.
+        self.descent = None
+
+        # Height of the line's line box, in points.
+        self.lineHeight = None
+
+        # External leading of the line (the leading minus the line's
+        # ascent and descent).
+        self.externalLeading = None
+
+        # Distance from the top of the line's line box to its baseline.
+        self.distanceToBaseline = None
+        
+        # The bounding box in screen coordinates, relative to the
+        # top-left of the line's line box.
+        self.xMin = None
+        self.yMin = None
+        self.xMax = None
+        self.yMax = None
+
+    def layout( self, alignment, width, lineHeight ):
+        """
+        Lays out the glyphs on the line; this should be called after
+        adding all glyphs to the line, but before drawing it.
+
+        Takes as parameters the alignment of the line ('left',
+        'right', 'center', or 'justify'), the width of the line in
+        points, and the line height in points.
+        """
+        
+        # Local variables xMin, xMax, yMin, and yMax are used here to
+        # correspond to their values in this image:
+        # http://freetype.sourceforge.net/freetype2/docs/glyphs/Image3.png
+        
+        # Cut off a trailing whitespace character, if it exists.
+        if len( self.glyphs ) > 1 and self.glyphs[-1].isWhitespace:
+            self.glyphs = self.glyphs[:-1]
+
+        # Determine our bounding box.
+        INFINITY = 999999999
+        
+        xMin = INFINITY
+        xMax = -INFINITY
+        yMin = INFINITY
+        yMax = -INFINITY
+
+        # Calculate the line's bounding box relative to the baseline
+        # origin of the line.
+        for glyph in self.glyphs:
+            glyphXMin = glyph.pos + glyph.fontGlyph.xMin
+            glyphXMax = glyph.pos + glyph.fontGlyph.xMax
+            glyphYMin = glyph.fontGlyph.yMin
+            glyphYMax = glyph.fontGlyph.yMax
+
+            if glyphXMin < xMin:
+                xMin = glyphXMin
+            if glyphXMax > xMax:
+                xMax = glyphXMax
+            if glyphYMin < yMin:
+                yMin = glyphYMin
+            if glyphYMax > yMax:
+                yMax = glyphYMax
+
+        bboxWidth = xMax - xMin
+        if alignment == "left":
+            self.__alignOfs = -xMin
+        elif alignment == "right":
+            self.__alignOfs = width - xMax
+        elif alignment == "center":
+            self.__alignOfs = (width / 2) - (bboxWidth / 2)
+        elif alignment == "justify":
+            # First, left align our text.
+            self.__alignOfs = -xMin
+            # Next, figure out how much extra padding we need per
+            # space character.
+            spaceCount = 0
+            for glyph in self.glyphs:
+                if glyph.isWhitespace:
+                    spaceCount += 1
+            if spaceCount == 0:
+                # No spaces in this line!  We'll just have to
+                # left-align this one.
+                self.__alignOfs = -xMin
+            else:
+                widthNeeded = width - bboxWidth
+                self.__ofsPerSpace = widthNeeded / spaceCount
+                xMax = width
+        else:
+            raise InvalidAlignmentError( alignment )
+
+        # Determine some line metrics information.
+        self.ascent = max( [glyph.font.ascent for glyph in self.glyphs] )
+        self.descent = max( [glyph.font.descent for glyph in self.glyphs] )
+
+        self.lineHeight = lineHeight
+        self.externalLeading = ( self.lineHeight -
+                                 (self.ascent +
+                                  self.descent) )
+        self.distanceToBaseline = ( self.externalLeading / 2.0 ) + \
+                                  self.ascent
+        
+        # Set the bounding box in screen coordinates, relative to the
+        # top-left of the line's line box.
+        self.xMin = xMin + self.__alignOfs
+        self.yMin = -yMax + self.distanceToBaseline
+        self.xMax = xMax + self.__alignOfs
+        self.yMax = -yMin + self.distanceToBaseline
+
+    def addGlyphs( self, glyphs ):
+        """
+        Adds the given glyphs to the end of the line.
+        """
+        
+        if len( self.glyphs ) > 0:
+            lastGlyph = self.glyphs[-1]
+        else:
+            lastGlyph = None
+
+        for glyph in glyphs:
+            if lastGlyph:
+                # Perform kerning, if possible.
+                if ( glyph.font == lastGlyph.font ):
+                    kernDist = glyph.font.getKerningDistance(
+                        lastGlyph.char, glyph.char
+                        )
+                    self.__cursorPos += kernDist
+            glyph.pos = self.__cursorPos
+            self.__cursorPos += glyph.fontGlyph.advance
+            lastGlyph = glyph
+        self.glyphs.extend( glyphs )
+
+    def removeGlyph( self ):
+        """
+        Removes the last glyph from the line.
+        """
+
+        removedGlyph = self.glyphs.pop()
+        self.__cursorPos = removedGlyph.pos
+
+    def ellipsify( self, ellipsisGlyph, maxWidth ):
+        """
+        'Ellipsifies' this line by removing its current glyphs until
+        this line with the given ellipsis glyph appended is shorter
+        than the given maximum width.  Then, the ellipsis glyph is
+        appended to the line.
+        """
+        
+        ellipsisWidth = ellipsisGlyph.fontGlyph.advance
+
+        # Remove glyphs (if necessary) until there's enough room on
+        # this line for an ellipsis.
+        while self.__cursorPos + ellipsisWidth > maxWidth:
+            self.removeGlyph()
+
+        # Add the ellipsis to the end of this line.
+        self.addGlyphs( [ellipsisGlyph] )
+
+    def draw( self, x, y, cairoContext ):
+        """
+        Draws the line to the given cairo context so that the top-left
+        of the line's line box is at the given coordinates, in points.
+        """
+        
+        y += self.distanceToBaseline
+        spaceOfs = 0.0
+        glyphX = 0.0
+        currFont = None
+        for glyph in self.glyphs:
+            glyphX = spaceOfs + \
+                     self.__alignOfs + \
+                     x + \
+                     glyph.pos
+
+            if not glyph.isWhitespace:
+                if currFont != glyph.font:
+                    currFont = glyph.font
+                    currFont.loadInto( cairoContext )
+                cairoContext.set_source_rgba( *glyph.color )
+                cairoContext.move_to( glyphX, y )
+
+                cairoContext.show_text( glyph.charAsUtf8 )
+            else:
+                spaceOfs += self.__ofsPerSpace
+
+
+class InvalidAlignmentError( Exception ):
+    """
+    Exception raised when an invalid alignment is used as an argument
+    to a function or method.
+    """
+    
+    pass
+
+
+# ----------------------------------------------------------------------------
+# The Glyph Element
+# ----------------------------------------------------------------------------
+
+class Glyph:
+    """
+    The smallest element of text layout, the glyph encapsulates a
+    single character on a line, including its font, style, size, and
+    color.
+    """
+
+
+    def __init__( self, fontGlyph, color ):
+        """
+        Creates the glyph from the given font glyph and color.
+        """
+        
+        self.fontGlyph = fontGlyph
+        self.color = color
+        self.pos = 0.0
+
+        # These are just copies of attributes from fontGlyph to make
+        # their lookup easier.
+        self.char = fontGlyph.char
+        self.charAsUtf8 = fontGlyph.charAsUtf8
+        self.font = fontGlyph.font
+
+        # Whether this glyph represents valid, breaking whitespace.
+        self.isWhitespace = (self.char == " ")
+
+    def __repr__( self ):
+        """
+        Returns a textual representation of this glyph for debugging.
+        """
+
+        char = self.char.encode( "ascii", "replace" )
+        return "<TextLayout Glyph '%s'>" % char