Text along a path in Flash

1st October 2008 @ 8:56 pm
nowhere
Trevor McCauley did the hard part years ago, as is often the case, but it seems like there's no source code out there showing text along a path in Flash using actionscript 3. I'm not the only one thinking about this – the degrafa folks have got the extremely capable algorithmist Jim Armstrong looking into the problem too.

I don't have time to write a full explanation tonight (packing calls, tomorrow I'm in Montréal for Design Engaged), but I've got a quick solution which might be of use to you if you googled upon this page. Read on for more if you're interested in a quick overview.

Here's a test showing two stars, one created from the outside-in and one created from the inside-out.

[kml_flashembed movie="http://www.tom-carden.co.uk/wp-content/uploads/2008/10/pathtests.swf" height="400" width="500" bgcolor="#ffffff" /]

The source code:

package { import com.senocular.drawing.Path; import flash.display.Sprite; import flash.filters.GlowFilter; import flash.geom.Point; import flash.text.TextFormat; import flash.text.TextFormatAlign;

[SWF(backgroundColor='#ffffff')] public class PathTests extends Sprite { [Embed(systemFont="Helvetica Neue", fontName="Helvetica", mimeType='application/x-font', unicodeRange='U+00A0,U+0020-U+007E,U+00A1-U+00BF,U+02BB-U+02BC,U+2010-U+2015,U+2018-U+201D,U+2024-U+2026')] private static var _ignoreMe:String; [Embed(systemFont="Helvetica Neue", fontName="Helvetica", fontWeight="bold", mimeType='application/x-font', unicodeRange='U+00A0,U+0020-U+007E,U+00A1-U+00BF,U+02BB-U+02BC,U+2010-U+2015,U+2018-U+201D,U+2024-U+2026')] private static var _ignoreMeToo:String; public function PathTests() { stage.align = 'TL'; stage.scaleMode = 'noScale';

drawStar(new Point(stage.stageWidth/4, stage.stageHeight/2), true); drawStar(new Point(3*stage.stageWidth/4, stage.stageHeight/2), false); } public function drawStar(center:Point, inToOut:Boolean):void { var textFormat:TextFormat = new TextFormat("Helvetica", 11, 0x000000, true); textFormat.kerning = true; textFormat.letterSpacing = 0; textFormat.align = TextFormatAlign.CENTER;

var innerRadius:Number = 50; var outerRadius:Number = 125; var midRadius:Number = (innerRadius + (outerRadius-innerRadius)/2); var path:Path; var paths:Array = []; for (var a:Number = 0; a < 2*Math.PI; a+=Math.PI/18) { path = new Path(); paths.push(path); if (inToOut) { path.moveTo(center.x + innerRadius * Math.cos(a), center.y + innerRadius * Math.sin(a)); path.lineTo(center.x + midRadius * Math.cos(a - Math.PI/36), center.y + midRadius * Math.sin(a - Math.PI/36)); path.lineTo(center.x + outerRadius * Math.cos(a), center.y + outerRadius * Math.sin(a)); } else { path.moveTo(center.x + outerRadius * Math.cos(a), center.y + outerRadius * Math.sin(a)); path.lineTo(center.x + midRadius * Math.cos(a + Math.PI/36), center.y + midRadius * Math.sin(a + Math.PI/36)); path.lineTo(center.x + innerRadius * Math.cos(a), center.y + innerRadius * Math.sin(a)); } var pathField:TextPathField = new TextPathField('Sample text', path, textFormat); pathField.filters = [ new GlowFilter(0xffffff, 1, 2, 2, 4, 1, false, false) ] addChild(pathField); } graphics.lineStyle(13,0xff9900); for each (path in paths) { path.draw(graphics); } graphics.lineStyle(11,0xffff00); for each (path in paths) { path.draw(graphics); } } } }

And the TextPathField code that powers it:

package { import com.senocular.drawing.Path; import flash.display.Sprite; import flash.geom.Matrix; import flash.geom.Point; import flash.geom.Rectangle; import flash.text.AntiAliasType; import flash.text.GridFitType; import flash.text.TextField; import flash.text.TextFormat; import flash.text.TextFormatAlign; /** depends on com.senocular.drawing.Path */ public class TextPathField extends Sprite { public static const DEFAULT_TEXTFIELD_PARAMS:Object = { embedFonts: true, antiAliasType: AntiAliasType.ADVANCED, gridFitType: GridFitType.NONE };

// TODO: getters and setters that call redraw public var text:String; public var path:Path; public var textFormat:TextFormat; public var textFieldParams:Object; public function TextPathField(text:String, path:Path, textFormat:TextFormat, textFieldParams:Object=null) { mouseEnabled = false; mouseChildren = false; this.text = text; this.path = path; this.textFormat = textFormat; this.textFieldParams = textFieldParams || DEFAULT_TEXTFIELD_PARAMS; redraw(); }

public function redraw():void { // clear everything away... while (numChildren > 0) { removeChildAt(0); } // make a dummy textfield so we can measure its width var textField:TextField = new TextField(); applyParamsToField(textField); textField.defaultTextFormat = textFormat; textField.text = text; textField.width = textField.textWidth+4; textField.height = textField.textHeight+4;

// bail out if there's no room if (path.length < textField.width) return; var t1:Number; var t2:Number; if (textFormat.align == TextFormatAlign.LEFT || textFormat.align == null) { t1 = 0; t2 = textField.width / path.length; } else if (textFormat.align == TextFormatAlign.RIGHT) { t1 = 1.0 - (textField.width / path.length); t2 = 1.0; } else if (textFormat.align == TextFormatAlign.CENTER) { t1 = (path.length/2 - textField.width/2) / path.length; t2 = (path.length/2 + textField.width/2) / path.length; } else { // throw error? trace('justify alignment unsupported in TextPathField'); }

var angleOffset:Number; // so we can do a 180º if we're running backwards var offsetSign:Number; // -1 if we're starting at t2 var tStart:Number; // t1 or t2

// TODO: there are probably more than two cases here...? if (path.pointAt(t1).x < path.pointAt(t2).x && path.angleAt(t1) < Math.PI/2 && path.angleAt(t1) > -Math.PI/2) { angleOffset = 0; offsetSign = 1; tStart = t1; } else { // this catches text that's running right to left or upside down angleOffset = Math.PI; offsetSign = -1; tStart = t2; }

// make a textfield for each char, centered on the line, using getCharBoundaries to rotate it around its center point var chars:Array = text.split(''); for (var i:int = 0; i < chars.length; i++) { var rect:Rectangle = textField.getCharBoundaries(i); var yOffset:Number = textField.height/2; if (rect) { var t:Number = tStart + offsetSign*(rect.left+rect.width/2)/path.length; addCharTextField(chars[i], path.pointAt(t), new Point(rect.width/2, yOffset), angleOffset+path.angleAt(t)); } } }

// place the given character at pt, registered using rpt, and rotated by r radians private function addCharTextField(char:String, pt:Point, rpt:Point, r:Number):void { var textField:TextField = new TextField(); applyParamsToField(textField); textField.defaultTextFormat = textFormat; textField.text = char; textField.width = textField.textWidth+4; textField.height = textField.textHeight+4; var matrix:Matrix = new Matrix(); matrix.translate(-rpt.x, -rpt.y); matrix.rotate(r); //matrix.translate(rpt.x, rpt.y); matrix.translate(pt.x, pt.y); textField.transform.matrix = matrix; addChild(textField); }

    private function applyParamsToField(textField:TextField):void
    {
      for (var param:String in textFieldParams) {
        if (textField[param]) {
          textField[param] = textFieldParams[param];
        }
      }
    }

}

}

Hopefully I can post more about how I'm using this class soon.