adobe-webplatform / Snap.svg

The JavaScript library for modern SVG graphics.
http://snapsvg.io
Apache License 2.0
13.97k stars 1.15k forks source link

FEAT: Extended Text Support #112

Open Siyfion opened 11 years ago

Siyfion commented 11 years ago

I think that one of the things that is most lacking from "native" SVG and thus, one of the most useful things that an SVG library could provide, is extended text support for use-cases like word-wrapping, shrink-to-fit, etc.

Essentially, very similar to what the the Prawn PDF library exposes (http://prawn.majesticseacreature.com/manual.pdf page 33-onwards).

This would truely be something worth a lot to developers.

madmed88 commented 10 years ago

+1000 for text wrapping!

wattsbn commented 10 years ago

I would love to have support text clipping and/or automatically adding an ellipsis. Being able to have text limited in length by an svg rectangle for example.

Siyfion commented 10 years ago

I have implemented some basic text wrapping, alignment (horizontal & vertical) and auto font sizing in my current project. While it's certainly not ready for merging into Snap, it could be a good reference for someone doing something similar...

'use strict'

angular.module('ClientApp')
  .factory 'TextObject', ($timeout, FreeTransform) ->

    class TextObject
      constructor: (@snapSVG, @objectData, @labelPart) ->

        @config = @objectData.settings

        # Setup the configuration object with some safe default if
        # there is any data missing from the object.
        @config.x = @config.x || 0
        @config.y = @config.y || 0
        @config.rt = @config.rt || 0
        @config.width = @config.width || 100
        @config.height = @config.height || 100
        @config.hAlign = @config.hAlign || 'middle'
        @config.vAlign = @config.vAlign || 'middle'
        @config.fontSize = @config.fontSize || 16
        @config.fontFamily = @config.fontFamily || 'Lato'
        @config.fontStyle = @config.fontStyle || 'normal'
        @config.fontWeight = @config.fontWeight || 'normal'
        @config.textDecoration = @config.textDecoration || 'none'

        # Set the object to be
        # deselected by default
        @selected = false

        # Create the free transform wrapper ready to attach
        @ft = new FreeTransform(@snapSVG, @)

        # Load the font used by this element and
        # then finally create the element to display to the user
        WebFont.load
          google:
            families: [ @config.fontFamily ]
          fontactive: (familyName, fvd) =>
            # Create the element with the correct font
            @_createElement()

        # Attach svgObject value to the template object data.
        @objectData._svgObject = @

      onSelected: (callbackFn) ->
        @onSelectFn = callbackFn

      bindingChanged: ->
        @_createElement()

      _createElement: ->
        @text = @objectData.binding
        @svgElement.remove() if @svgElement?

        fontSize = @config.fontSize
        loop
          break if @_calculateText(fontSize)
          break if fontSize == 1
          fontSize -= 1

        svgText = @_attemptRender(fontSize)
        rectBoundary = @_drawTextBox()

        # Create a group holding both the text and the
        # rectangle that represents the boundaries.
        @svgElement = @snapSVG.group svgText, rectBoundary

        # Attach the free transform wrapper
        # to correctly position, rotate and scale the object.
        @ft.initialize()

        if @selected
          @ft.showHandles()

        @svgElement.click =>
          if @onSelectFn?
            @selected = true
            @onSelectFn()

        # Attach this value to the child SVG element group.
        @svgElement.parentObject = @

      # Attempt to split the text so that it will fit into
      # the bounding box provided. Returns false if the font-size
      # needs to be lowered.
      _calculateText: (fontSize) ->
        # Create the array to hold the lines
        @lines = []

        # Running counter of existing lines height
        totalHeight = 0

        # Separate the text into paragraphs
        paragraphs = _.str.lines(@text || '')

        # Parse paragraphs one at a time
        i = paragraphs.length
        while i--
          # Prepare line
          line = ''
          words = _.str.words paragraphs.shift()

          # Add words one at a time
          w = words.length
          while w--
            word = words.shift()

            # Try out this line with the word added
            testText = @snapSVG.text 0, 0, line + word
            testText.attr
              'font-family': @config.fontFamily
              'font-size': fontSize + 'px'
              'font-style': @config.fontStyle
              'font-weight': @config.fontWeight
              'text-decoration': @config.textDecoration

            # Measure for width and height overflow
            box = testText.getBBox()

            # Delete the SVG element as it's not longer needed
            testText.remove()

            if box.width <= @config.width
              # Add a space before adding the word (if required)
              line += (if line.length > 0 then ' ' else '') + word
            else if line == ''
              # We can't fit a single word into the width
              # so we need to reduce the font-size and try again
              return false
            else
              # Add the height of this line to the total
              totalHeight += fontSize

              if totalHeight > @config.height
                # We can't fit the text vertically into the height
                # anymore, so reduce the font-size and try again.
                return false
              else
                # Couldn't fit the word on, so push the line (as it stands) on
                @lines.push line

                # If this is the last word
                if w == 0
                  # We need to check it fits width-wise before just
                  # pushing it onto the end.
                  testText = @snapSVG.text 0, 0, word
                  testText.attr
                    'font-family': @config.fontFamily
                    'font-size': fontSize + 'px'
                    'font-style': @config.fontStyle
                    'font-weight': @config.fontWeight
                    'text-decoration': @config.textDecoration

                  # Measure for width and height overflow
                  box = testText.getBBox()

                  # Delete the SVG element as it's not longer needed
                  testText.remove()

                  # Check the word width doesn't go over the limit.
                  if box.width > @config.width
                    return false

                # Add the word that didn't fit to the next line calculation
                line = word

          # Add the height of this line to the total
          totalHeight += fontSize

          if totalHeight > @config.height
            # We can't fit the text vertically into the height
            # anymore, so reduce the font-size and try again.
            return false

          # Add the last line
          @lines.push line

        # Return success!
        return true

      # Attempt to render the final text to the designer,
      # taking into account the alignment and positioning settings.
      _attemptRender: (fontSize) ->
        # Set the text display coords to 0,0 to
        # begin with; position will be via the group.
        textX = textY = 0.0

        # Change simple left, middle, right to the
        # correlating font-anchor values.
        hAlign = 'middle'
        switch @config.hAlign
          when 'left'
            hAlign = 'start'
          when 'middle'
            textX = @config.width / 2.0
          when 'right'
            hAlign = 'end'
            textX = @config.width

        deltaY = 0
        switch @config.vAlign
          # Setting 1em for the first line brings it
          # inside the box, rather than sitting ontop of it.
          when 'top' then deltaY = '1em'
          when 'middle' then deltaY = '1em'
          when 'bottom'
            textY = @config.height
            deltaY = -(@lines.length - 1) + 'em'

        finalText = @snapSVG.text textX, textY, @lines
        finalText.attr
          'font-family': @config.fontFamily
          'font-size': fontSize + 'px'
          'font-style': @config.fontStyle
          'font-weight': @config.fontWeight
          'text-decoration': @config.textDecoration
          'text-anchor': hAlign

        tspans = finalText.selectAll('tspan')
        for tspan, index in tspans
          tspan.attr
            'xml:space': 'preserve'
            x: textX
            dy: if index == 0 then deltaY else '1em'

        if @config.vAlign is 'middle'
          box = finalText.getBBox()
          textY = (@config.height - box.height) / 2.0
          finalText.attr
            y: textY

        return finalText

      _drawTextBox: ->
        svgRect = @snapSVG.rect 0.0, 0.0, @config.width, @config.height
        svgRect.attr
          fill: 'white'
          'fill-opacity': 0.0
          stroke: 'black'
          'stroke-width': 0.5
          'stroke-dasharray': '5 2'