Mercurial > enso_core
changeset 19:422dc98d3080
Added enso.graphics.measurement and enso.graphics.xmltextlayout.
author | Atul Varma <varmaa@toolness.com> |
---|---|
date | Fri, 22 Feb 2008 17:54:00 -0600 |
parents | 09b7a34603c0 |
children | 203d6a15652c |
files | enso/graphics/__init__.py enso/graphics/measurement.py enso/graphics/xmltextlayout.py |
diffstat | 2 files changed, 871 insertions(+), 0 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/enso/graphics/measurement.py Fri Feb 22 17:54:00 2008 -0600 @@ -0,0 +1,164 @@ +# 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.measurement +# +# ---------------------------------------------------------------------------- + +""" + Screen measurement-related functionality. + + This module handles coordinate conversion calculations and + maintains information on the pixel density of the screen. +""" + +# ---------------------------------------------------------------------------- +# Pixels-Per-Inch (PPI) Getter/Setter +# ---------------------------------------------------------------------------- + +DEFAULT_PPI = 96.0 + +_ppi = DEFAULT_PPI + +def setPixelsPerInch( ppi ): + """ + Sets the current PPI of the screen in the Measurement module. This + alters the state of the module, in that any functions depending on + the PPI of the screen will use the value passed into this + function. + + It is further assumed that the screen has square pixels (i.e., the + horizontal and vertical PPI of the screen are the same). + """ + + global _ppi + _ppi = float(ppi) + +def getPixelsPerInch(): + """ + Returns the current PPI of the screen in the Measurement module. + """ + + return _ppi + + +# ---------------------------------------------------------------------------- +# Unit-of-Measurement Conversion Functions +# ---------------------------------------------------------------------------- + +def pointsToPixels( points ): + """ + Converts the given number of points to pixels, using the current + PPI settings. + """ + + return points * getPixelsPerInch() / 72.0 + +def pixelsToPoints( pixels ): + """ + Converts the given number of pixels to points, using the current + PPI settings. + """ + + return pixels * 72.0 / getPixelsPerInch() + +def inchesToPoints( inches ): + """ + Converts the given number of inches to points. + """ + + return inches * 72.0 + +def picasToPoints( picas ): + """ + Converts the given number of picas to points. + """ + + return picas * 12.0 + +def calculateScreenPpi( screenDiagonal, hres, vres ): + """ + Given a screen's diagonal in inches, and the horizontal & + vertical resolution in pixels, calculates the pixels per inch of + the screen. + """ + + import math + diagonalInPixels = math.sqrt( hres**2 + vres**2 ) + return int( diagonalInPixels / screenDiagonal ) + +def convertUserSpaceToPoints( cairoContext ): + """ + Modifies the CTM of a Cairo Context so that all future drawing + operations on it can be specified in units of points rather than + pixels. + + It is assumed that prior to this call, the Cairo Context's CTM is + the identity matrix. + """ + + scaleFactor = getPixelsPerInch() / 72.0 + cairoContext.scale( scaleFactor, scaleFactor ) + +def strToPoints( unitsStr ): + """ + Converts from a string such as '2pt', '3in', '5pc', or '20px' into + a floating-point value measured in points. + + Examples: + + >>> setPixelsPerInch( 72 ) + >>> strToPoints( '1in' ) + 72.0 + >>> strToPoints( '1pt' ) + 1.0 + >>> strToPoints( '5pc' ) + 60.0 + >>> strToPoints( '72px' ) + 72.0 + >>> strToPoints( '125em' ) + Traceback (most recent call last): + ... + ValueError: Bad measurement string: 125em + """ + + # TODO: memoize this function for performance improvement. + + units = float( unitsStr[:-2] ) + if unitsStr.endswith( "pt" ): + return units + elif unitsStr.endswith( "in" ): + return inchesToPoints( units ) + elif unitsStr.endswith( "pc" ): + return picasToPoints( units ) + elif unitsStr.endswith( "px" ): + return pixelsToPoints( units ) + else: + raise ValueError( "Bad measurement string: %s" % unitsStr )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/enso/graphics/xmltextlayout.py Fri Feb 22 17:54:00 2008 -0600 @@ -0,0 +1,707 @@ +# 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.xmltextlayout +# +# ---------------------------------------------------------------------------- + +""" + Module for XML text layout. + + This module implements a high-level XML-based interface to the + textlayout module. It also provides a simple style mechanism that + is heavily based on the Cascading Style Sheets (CSS) system. +""" + +# ---------------------------------------------------------------------------- +# Imports +# ---------------------------------------------------------------------------- + +import xml.sax +import xml.sax.handler + +from enso.graphics import measurement +from enso.graphics import textlayout +from enso.graphics import font + + +# ---------------------------------------------------------------------------- +# Constants +# ---------------------------------------------------------------------------- + +# Ordinarily, we'd use the unicodedata module for this, but it's a +# hefty file so we'll just define values here. +NON_BREAKING_SPACE = u"\u00a0" + + +# ---------------------------------------------------------------------------- +# Utility functions +# ---------------------------------------------------------------------------- + +def colorHashToRgba( colorHash ): + """ + Converts the given HTML-style color hash (e.g., '#aabbcc') or + HTML-with-alpha color hash (e.g. '#aabbccdd') to a quad-color (r, + g, b, a) tuple and returns the result. + + Examples: + + >>> colorHashToRgba( '#ffffff' ) + (1.0, 1.0, 1.0, 1.0) + + >>> colorHashToRgba( '#ff000000' ) + (1.0, 0.0, 0.0, 0.0) + """ + + # TODO: Memoize this function. + + colorHash = colorHash[1:] + if len(colorHash) == 6: + # It's a RGB hash. + alphaHex = "FF" + elif len(colorHash) == 8: + # It's a RGBA hash. + alphaHex = colorHash[6:8] + else: + raise ValueError("Can't parse color hash for '#%s'" % colorHash) + + redHex = colorHash[0:2] + greenHex = colorHash[2:4] + blueHex = colorHash[4:6] + + red = float( int(redHex, 16) ) + green = float( int(greenHex, 16) ) + blue = float( int(blueHex, 16) ) + alpha = float( int(alphaHex, 16) ) + + return ( red / 255.0, green / 255.0, blue / 255.0, alpha / 255.0 ) + + +def stringToBool( string ): + """ + Converts a string with the contents 'true' or 'false' to the + appropriate boolean value. + + Examples: + + >>> stringToBool( 'true' ) + True + + >>> stringToBool( 'false' ) + False + + >>> stringToBool( 'True' ) + Traceback (most recent call last): + ... + ValueError: can't convert to boolean: True + """ + + if string == "true": + return True + elif string == "false": + return False + else: + raise ValueError( "can't convert to boolean: %s" % string ) + + +# ---------------------------------------------------------------------------- +# Style Properties +# ---------------------------------------------------------------------------- + +# Style properties that are inherited from a parent element to a child +# element. +STYLE_INHERITED_PROPERTIES = [ + # The following properties are identical to the CSS properties of + # the same name, with the exception that any underscores should be + # replaced by hyphens. + "width", + "text_align", + "line_height", + "color", + "font_style", + "font_family", + "font_size", + + # This property defines the maximum number of lines that the + # element can contain, and is only valid for Block elements; if + # the element's lines exceed this number and the 'ellipsify' + # property is false, a textlayout.MaxLinesExceededError is raised. + "max_lines", + + # This property defines whether or not to truncate a Block element + # with an ellipsis ('...') if the Block's number of lines exceeds + # that prescribed by the "max_lines" property. + "ellipsify" + ] + + +# Style properties that are uninherited from a parent element to a +# chid element. +STYLE_UNINHERITED_PROPERTIES = [ + # The following properties are identical to the CSS properties of + # the same name, with the exception that any underscores should be + # replaced by hyphens. + "margin_top", + "margin_bottom" + ] + + +# All possibilities of styles defined by this module. +STYLE_PROPERTIES = ( + STYLE_INHERITED_PROPERTIES + + STYLE_UNINHERITED_PROPERTIES + ) + + +# ---------------------------------------------------------------------------- +# Style Registry +# ---------------------------------------------------------------------------- + +class StyleRegistry: + """ + Registry of styles used by XML text layout markup. Note that this + class is not a singleton; rather, one StyleRegistry instance + exists for each document that the client wants to layout. + """ + + def __init__( self ): + """ + Creates an empty StyleRegistry object. + """ + + self._styleDict = {} + + def __validateKeys( self, dict ): + """ + Makes sure that the keys of dict are the names of valid style + properties. + """ + + invalidKeys = [ key for key in dict.keys() \ + if key not in STYLE_PROPERTIES ] + if len( invalidKeys ) > 0: + raise InvalidPropertyError( str(invalidKeys) ) + + + def add( self, selector, **properties ): + """ + Adds the given style selector with the given properties to the + style registry. If any of the properties are invalid, an + InvalidPropertyError is thrown. + + Examples: + + >>> styles = StyleRegistry() + >>> styles.add( 'document', width = '1000pt' ) + >>> styles.add( 'p', foo = '1000pt' ) + Traceback (most recent call last): + ... + InvalidPropertyError: ['foo'] + + It should also be noted that the same style selector can't be + defined more than once, e.g.: + + >>> styles.add( 'foo', width = '1000pt' ) + >>> styles.add( 'foo', width = '1000pt' ) + Traceback (most recent call last): + ... + ValueError: Style 'foo' already exists. + """ + + if self._styleDict.has_key( selector ): + raise ValueError( "Style '%s' already exists." % selector ) + + self.__validateKeys( properties ) + self._styleDict[ selector ] = properties + + def findMatch( self, selector ): + """ + Given a selector, returns the style dictionary corresponding + to it. If no match is found, this method returns None. + + Each key of the returned style dictionary corresponds to a + style property, while each value corresponds to the value of + the style property. + + Examples: + + >>> styles = StyleRegistry() + >>> styles.add( 'document', width = '1000pt' ) + >>> styles.findMatch( 'document' ) + {'width': '1000pt'} + + >>> styles.findMatch( 'mystyle' ) == None + True + """ + + return self._styleDict.get( selector, None ) + + def update( self, selector, **properties ): + """ + Updates the styles for selector to those described by + properties. + + Examples: + + >>> styles = StyleRegistry() + >>> styles.add( 'document', width = '1000pt' ) + >>> styles.update( 'document', margin_top = '24pt' ) + >>> styles.findMatch( 'document' ) + {'width': '1000pt', 'margin_top': '24pt'} + """ + + assert selector in self._styleDict.keys() + + self.__validateKeys( properties ) + self._styleDict[ selector ].update( properties ) + + +class InvalidPropertyError( Exception ): + """ + Exception raised by the StyleRegistry when a style with invalid + properties is added to the registry. + """ + + pass + + +# ---------------------------------------------------------------------------- +# Cascading Style Stack +# ---------------------------------------------------------------------------- + +class CascadingStyleStack: + """ + Encapsulates the CSS-like 'cascading' style mechanism supported by + the XML text layout markup. + """ + + # This is just a set version of STYLE_UNINHERITED_PROPERTIES. + uninheritedProps = set( STYLE_UNINHERITED_PROPERTIES ) + + def __init__( self ): + """ + Creates an empty stack. + """ + + self.__stack = [] + + def push( self, newStyle ): + """ + Push a new style onto the Cascading Style Stack, making it the + current style. + """ + + if len( self.__stack ) > 0: + # "Cascade" the new style by combining it with our current + # style, removing any uninherited properties first. + + currStyle = self.__stack[-1].copy() + props = self.uninheritedProps.intersection( currStyle.keys() ) + + for key in props: + del currStyle[key] + + currStyle.update( newStyle ) + self.__stack.append( currStyle ) + else: + # Set this style as our current style. + + self.__stack.append( newStyle ) + + def pop( self ): + """ + Remove the current style from the Cascading Style Stack. + """ + + self.__stack.pop() + + def _strToPoints( self, unitsStr ): + """ + Converts from a string such as '1em', '2pt', '3in', '5pc', or + '20px' into a floating-point value measured in points. + """ + + if unitsStr.endswith( "em" ): + currEmSizeStr = self.__stack[-1]["font_size"] + currEmSize = self._strToPoints( currEmSizeStr ) + units = float( unitsStr[:-2] ) + return units * currEmSize + else: + return measurement.strToPoints( unitsStr ) + + def _propertyToPoints( self, propertyName ): + """ + Converts the value of the given property name into a + floating-point value measured in points. + """ + + propertyStr = self.__stack[-1][propertyName] + return self._strToPoints( propertyStr ) + + def _propertyToInt( self, propertyName ): + """ + Converts the value of the given property name into an integer + value. + """ + + return int( self.__stack[-1][propertyName] ) + + def _propertyToBool( self, propertyName ): + """ + Converts the value of the given property name into a boolean + value. + """ + + return stringToBool( self.__stack[-1][propertyName] ) + + def _propertyToColor( self, propertyName ): + """ + Converts the value of the given property name into a (r, g, b, + a) color tuple. + """ + + return colorHashToRgba( self.__stack[-1][propertyName] ) + + def _property( self, propertyName ): + """ + Returns the value of the given property name as a string. + """ + + return self.__stack[-1][propertyName] + + def makeNewDocument( self ): + """ + Makes a new document with the current style. + """ + + document = textlayout.Document( + width = self._propertyToPoints("width"), + marginTop = self._propertyToPoints("margin_top"), + marginBottom = self._propertyToPoints("margin_bottom"), + ) + + return document + + def makeNewBlock( self ): + """ + Makes a new block with the current style. + """ + + block = textlayout.Block( + width = self._propertyToPoints("width"), + lineHeight = self._propertyToPoints("line_height"), + marginTop = self._propertyToPoints("margin_top"), + marginBottom = self._propertyToPoints("margin_bottom"), + textAlign = self._property("text_align"), + maxLines = self._propertyToInt("max_lines"), + ellipsify = self._propertyToBool("ellipsify") + ) + + return block + + def makeNewGlyphs( self, characters ): + """ + Makes new glyphs with the current style. + """ + + glyphs = [] + + font = font.theFontRegistry.get( + self._property( "font_family" ), + self._propertyToPoints( "font_size" ), + self._property( "font_style" ) == "italic" + ) + + color = self._propertyToColor( "color" ) + + for char in characters: + fontGlyph = font.getGlyph( char ) + glyph = textlayout.Glyph( + fontGlyph, + color, + ) + glyphs.append( glyph ) + + return glyphs + + +# ---------------------------------------------------------------------------- +# XML Markup Tag Aliases +# ---------------------------------------------------------------------------- + +class XmlMarkupTagAliases: + """ + Implementation of XML markup tag aliases, a simple feature that + allows one tag name to be aliased as another tag name. + """ + + def __init__( self ): + """ + Creates an empty set of tag aliases. + """ + + self._aliases = {} + + def add( self, name, baseElement ): + """ + Adds a tag alias; 'name' will now be an alias for + 'baseElement'. + + The following example sets up tag aliases for <p> and + <caption> tags: + + >>> tagAliases = XmlMarkupTagAliases() + >>> tagAliases.add( 'p', baseElement = 'block' ) + >>> tagAliases.add( 'caption', baseElement = 'block' ) + + It should also be noted the same tag alias can't be defined + more than once, e.g.: + + >>> tagAliases.add( 'foo', baseElement = 'inline' ) + >>> tagAliases.add( 'foo', baseElement = 'block' ) + Traceback (most recent call last): + ... + ValueError: Tag alias 'foo' already exists. + """ + + if self._aliases.has_key( name ): + raise ValueError( "Tag alias '%s' already exists." % name ) + + self._aliases[name] = baseElement + + def get( self, name ): + """ + Retrieves the tag that the given name is an alias for. + + Example: + + >>> tagAliases = XmlMarkupTagAliases() + >>> tagAliases.add( 'p', baseElement = 'block' ) + >>> tagAliases.get( 'p' ) + 'block' + >>> tagAliases.get( 'caption' ) + Traceback (most recent call last): + ... + KeyError: 'caption' + """ + + return self._aliases[name] + + def has( self, name ): + """ + Returns whether or not the given name is an alias for a tag. + + Example: + + >>> tagAliases = XmlMarkupTagAliases() + >>> tagAliases.add( 'p', baseElement = 'block' ) + >>> tagAliases.has( 'p' ) + True + >>> tagAliases.has( 'caption' ) + False + """ + + return self._aliases.has_key( name ) + + +# ---------------------------------------------------------------------------- +# XML Markup Content Handler +# ---------------------------------------------------------------------------- + +class _XmlMarkupHandler( xml.sax.handler.ContentHandler ): + """ + XML content handler for XML text layout markup. + """ + + def __init__( self, styleRegistry, tagAliases=None ): + """ + Initializes the content handler with the given style registry + and tag aliases. + """ + + xml.sax.handler.ContentHandler.__init__( self ) + self.styleRegistry = styleRegistry + + if not tagAliases: + tagAliases = XmlMarkupTagAliases() + self.tagAliases = tagAliases + + def startDocument( self ): + """ + Called by the XML parser at the beginning of parsing the XML + document. + """ + + self.style = CascadingStyleStack() + self.document = None + self.block = None + self.glyphs = None + + def _pushStyle( self, name, attrs ): + """ + Sets the current style to the style defined by the "style" + attribute of the given tag. If that style doesn't exist, we + use the style named by the tag. + """ + + styleDict = None + + styleAttr = attrs.get( "style", None ) + if styleAttr: + styleDict = self.styleRegistry.findMatch( styleAttr ) + + if styleDict == None: + styleDict = self.styleRegistry.findMatch( name ) + + if styleDict == None: + raise ValueError, "No style found for: %s, %s" % ( + name, + str( styleAttr ) + ) + + self.style.push( styleDict ) + + def startElement( self, name, attrs ): + """ + Handles the beginning of an XML element. + """ + + if name == "document": + self._pushStyle( name, attrs ) + self.document = self.style.makeNewDocument() + elif name == "block": + if not self.document: + raise XmlMarkupUnexpectedElementError( + "Block element encountered outside of document element." + ) + self._pushStyle( name, attrs ) + self.block = self.style.makeNewBlock() + self.glyphs = [] + elif name == "inline": + if not self.block: + raise XmlMarkupUnexpectedElementError( + "Inline element encountered outside of block element." + ) + self._pushStyle( name, attrs ) + elif self.tagAliases.has( name ): + baseElement = self.tagAliases.get( name ) + self.startElement( baseElement, { "style" : name } ) + else: + raise XmlMarkupUnknownElementError( name ) + + def endElement( self, name ): + """ + Handles the end of an XML element. + """ + + if name == "document": + self.style.pop() + self.document.layout() + elif name == "block": + ellipsisGlyph = self.style.makeNewGlyphs( u"\u2026" )[0] + self.block.setEllipsisGlyph( ellipsisGlyph ) + + self.style.pop() + self.block.addGlyphs( self.glyphs ) + self.document.addBlock( self.block ) + self.block = None + self.glyphs = None + elif name == "inline": + self.style.pop() + else: + baseElement = self.tagAliases.get( name ) + self.endElement( baseElement ) + + def characters( self, content ): + """ + Handles XML character data. + """ + + if self.glyphs != None: + self.glyphs.extend( self.style.makeNewGlyphs(content) ) + else: + # Hopefully, the content is just whitespace... + content = content.strip() + if content: + raise XmlMarkupUnexpectedCharactersError( content ) + + +class XmlMarkupUnknownElementError( Exception ): + """ + Exception raised when an unknown XML text layout markup element is + encountered. + """ + + pass + + +class XmlMarkupUnexpectedElementError( Exception ): + """ + Exception raised when a recognized, but unexpected XML text layout + markup element is encountered. + """ + + pass + + +class XmlMarkupUnexpectedCharactersError( Exception ): + """ + Exception raised when characters are encountered in XML text + layout in a place where they're not expected. + """ + + pass + + +# ---------------------------------------------------------------------------- +# XML Markup to Document Conversion +# ---------------------------------------------------------------------------- + +def xmlMarkupToDocument( text, styleRegistry, tagAliases=None ): + """ + Converts the given XML text into a textlayout.Document object that + has been fully laid out and is ready for rendering, using the + given style registry and tag alises. + """ + + import re + + # Convert all occurrences of multiple contiguous whitespace + # characters to a single space character. + text = re.sub( r"\s+", " ", text ) + + # Convert all occurrences of the non-breaking space character + # entity reference into its unicode equivalent (the SAX XML parser + # doesn't recognize this one on its own, sadly). + text = text.replace( " ", NON_BREAKING_SPACE ) + + xmlMarkupHandler = _XmlMarkupHandler( styleRegistry, tagAliases ) + xml.sax.parseString( text, xmlMarkupHandler ) + return xmlMarkupHandler.document