Simple Text Wrapping
September 11, 2002
Introduction
SVG 1.0 includes support for manipulating and
representing text. There's an entire chapter devoted to text in the specification. Text in SVG is real text; to write
Hello World!
in an SVG document, you have to write something like
<text>Hello World!</text>
. This comes in handy with regard to
accessibility as it means that SVG text is searchable and indexable. Looking through
the
chapter we can see a number of appealing text features: precise text positioning,
support
for bidirectional text, text on a path, and so on. However, you'll find that text
wrapping
is missing. Let's see what can be done with the current set of SVG 1.0 features to
extend it
to do some simple text wrapping.
The Task at Hand
Before delving into the problem, we should specify the features we want to support. The main thing is to be able to break a string into a multiline paragraph, given a column width. Next, we might take a crack at text alignment: left, right, center, and full justification. Line-breaking will only be done on spaces, no funny stuff with hyphens or dictionaries. That's it. For refinements, we'll consider CSS for font properties, line intervals, and text rendering quality. But we also want to provide a nice architecture for our component; we're going to give it a nice XML front-end. So here's what we came up with:
<text:wrap xmlns:text="http://xmlns.graougraou.com/svg/text/" x="10.5" y="47.5" width="440" style="font-family: arial; font-size: 11px; text-rendering: optimizeLegibility; text-align: justify; line-interval: 1.5em"><!-- your text here --></text:wrap>
We introduce a new element wrap
within a new namespace text
that
is singled out among other namespaces by its URI
http://xmlns.graougraou.com/svg/text/
. This new element
<text:wrap>
has four attributes. You can position your element using
the x
and y
attribute much in the same way you would position a
regular SVG <text>
element. We also define the attribute
width
to specify the width of the box we want our text to be wrapped into.
Having a width
attribute shows our strong bias for western languages -- no
vertical stuff here. We should really have come up with a length
attribute that
would have worked with vertical text as well since it would not have been that much
more
work. So with those three attributes we specify an abstract rectangle with a free
height
that will be the bounds for our wrapping. The style
attribute introduces CSS
properties for some simple text styling.
Before reading further, have a look at the demonstration file for the final component. The idea is to supply the user with two types of control so that they can interact with the component. They are provided with buttons to change the text alignment to left, right, center or justify, and increase or decrease the font-size. Here's what the demo looks like on a Mac:
A Quick Look at the TextWrap class
In order to implement this new <text:wrap>
element, we're going to
design a TextWrap JavaScript class that will interact with our JavaScript document.
Before
our class can do anything interesting, we need a way to analyze our document for these
new
elements. We're going to have the TextFlow._init()
method just for that:
TextWrap._init = function () { var elements = document.documentElement.getElementsByTagNameNS(this.ns, 'wrap'); for (var i=0; i<elements.length; i++) { this._instances.push( new TextWrap(i, elements.item(i)) ); } }
Previously in the code, we have declared two class members TextFlow.ns
and
TextFlow._instances
to match to our elements namespaces and store our new
TextFlow instances into an array:
TextWrap.ns = 'http://xmlns.graougraou.com/svg/text/'; TextWrap._instances = new Array();
Wwe need to bootstrap the script with the SVG so that our initialization method runs when the document is loaded, and so that our new elements are within the correct namespace. We also make sure that if the document is viewed with the latest incarnation of the Adobe SVG Viewer, then it will use its own scripting engine rather than the browser's. Our root element now looks like this:
<svg xmlns="http://www.w3.org/2000/svg" xmlns:text="http://xmlns.graougraou.com/svg/text/" xmlns:a3="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/" a3:scriptImplementation="Adobe" onload="TextWrap._init()">
What happens when we create a new TextWrap
instance and store it into our
_instances
array? Essentially, creating a new instance of a
TextWrap
object will call the TextWrap
constructor which looks
like this:
function TextWrap (id, node) { this._id = id; this._node = node; this._string = null; this._x = null; this._y = null; this._width = null; this._font = null; this._size = null; this._align = null; this._quality = null; this._interval = null; this._svg = null; this._lines = null; this._initialized = false; this._construct(); }
First, we keep track of the two parameters passed to our constructor. Looking back
at
_init()
we see that it passes an id and a pointer to the
<text:wrap>
element. Now, using _id
we will be able to
refer back to our instance within the _instances
array, and using
_node
we will be able to get and parse the data we need from our original
<text:wrap>
element. Then, we initialize a whole lot of instance
members in order to keep track of the string, the x and y position, the box width,
the font
and its size, alignment type, rendering quality, line interval, a pointer to the generated
SVG fragment, and finally a pointer to the array of lines that the text is broken
into.
Finally, our constructor says our current instance is not yet fully initialized and
calls
the _construct()
method that will go forward in the creation of our
text-wrapped paragraph. Actually, all of our fields were set to null
because we
will initialize all of them with a call to _construct()
:
TextWrap.prototype._construct = function () { this._build(); this._svg.setAttribute('style', this._node.getAttribute('style')); var style = this._svg.style; this._node.normalize(); this.setString( this._node.firstChild.data ); this.setX( parseInt(this._node.getAttribute('x')) ); this.setY( parseInt(this._node.getAttribute('y')) ); this.setWidth( parseInt(this._node.getAttribute('width')) ); this.setTextAlign( style.getPropertyValue('text-align') ); this.setFontFamily( style.getPropertyValue('font-family') ); this.setFontSize( style.getPropertyValue('font-size') ); this.setTextRendering( style.getPropertyValue('text-rendering') ); this.setLineInterval( style.getPropertyValue('line-interval') ); this._splitString(); this._layout(); this._initialized = true; }
Our _construct()
method is pretty simple. We start by asking our component to
build itself with a call to _build()
which will actually generate an SVG place
holder <text>
element. Then it's time for setting our object's fields. We
start off with a neat little trick we've used before (in a previous article about XForms widgets
generation) in order to get an actual CSS DOM CSSStyleDeclaration object from a simple
text string. Most of the data that we base field initialization on is stored as CSS
properties of our <text:wrap>
element. To get the actual string of the
element we normalize()
the node to make it one single text node. Then we
eventually initialize our fields...except we don't. We call a whole bunch of setter
methods
instead. Our fields are actually invisible to the user, much as if they were
private
-- that's why we have those funny "_
" in the field
names. So the only public
(or recommended) access to our API is through setter
methods. These are the same methods that we use in our demo when interacting with the toolbar at
the top. Clicking on the "+" icon will only make a call to setFontSize
. So once
we're done with all this we round off by calling two more methods. Now that we know
what the
string of our text is, we might want to get around splitting it into different lines
with
_splitString()
, and once the string is split we need to call for a new
_layout()
so our view is refreshed. So we set our _initialized
field to true
just in case we want to check everything is setup correctly.
Core Functionalities
Let's turn to the design of the resulting SVG code. First off, I want a container
SVG
<text>
element where I will put the text. You can have as many
<tspan>
elements as you want, but you only want a single
<text>
. In our case we only deal with one single paragraph per
<text:wrap>
element -- I was too lazy to do more -- so we're going to
end up with one <text>
element for each of our wrapped paragraphs and
that element will have one <tspan>
child element for each line it is
broken into. On a more pragmatic side, having all your text wrapped up in a single
<text>
element will allow the user to actually select it all in one go.
So our _build()
method is supposed to handle creating that container
<text>
element:
TextWrap.prototype._build = function () { var element = document.createElementNS(SVG.ns, 'text'); var node = this._node; var nextElement = null; while (node.nextSibling) { if (node.nextSibling.nodeType == 1) { nextElement = node.nextSibling; break; } else { node = node.nextSibling; } } if (nextElement) { var test = this._node.parentNode.insertBefore(element, nextElement); } else { this._node.parentNode.appendChild(element); } element.appendChild(document.createTextNode('')); this._svg = element; }
The only challenge so far was to append our new <text>
element right
after our original <text:wrap>
element. In order to do so we ended up
writing some code that could be taken into a new method called
Node.insertAfter()
. We need to start from our current node (the
<text:wrap>
element) and look for the next element node, not just any
node. Once we find it, we can call insertBefore
. If we don't find one, then we
will just have to append our new <text>
element to
<text:wrap>
's parent.
How about splitting the string into lines? That shows off the power of SVG nicely.
The real
headache here is knowing the pixel-length of a bunch of words so I can come up with
nice
little lines that take up a maximum of 440 pixels. We can add the words as we find
them to
an SVG <text>
element and query its bounding box to find out its width:
TextWrap.prototype._splitString = function () { this._hide(); this._clear(); var words = this._string.split(' '); var lines = new Array(); var line = new Array(); var length = 0; var prevLength = 0; while (words.length) { var word = words[0]; this._svg.firstChild.data = line.join(' ') + ' ' + word; length = this._svg.getComputedTextLength(); if (length > this._width) { if (!words.length) { line.push(words[0]); } lines.push( new Line(this, prevLength, line) ); line = new Array(); } else { line.push(words.shift()); } prevLength = length; if (words.length == 0) { lines.push( new Line(this, 0, line) ); } } this._lines = lines; }
So we took the simple route by only breaking lines at spaces. Recall that the design
is
biased against vertically-oriented languages because it has a width
attribute,
but no length
attribute. Actually I don't care if the input text is horizontal
or vertical in SVG, the SVG DOM method getComputedTextLength()
method gives me
the length of the text, whatever its direction. That's much better than getting the
width of
the bounding box. So now we have the line breaking bit figured out, how about having
the
layout done? We decided to handle four types of alignment. Our _layout()
method
does all the work of printing things on the screen:
TextWrap.prototype._layout = function () { this._clear(); var lines = (new Array(0)).concat(this._lines); var anchor = 'start'; if (this._align == 'center') { anchor = 'middle'; } else if (this._align == 'right') { anchor = 'end'; } for (var i=0; i<lines.length; i++) { var x = 0; line = lines[i]; this._svg.appendChild( document.createTextNode(' ') ); var tspan = document.createElementNS(SVG.ns, 'tspan'); tspan.appendChild( document.createTextNode(line._words.join(' ')) ); if (this._align == 'justify') { var space = (this._width - line._width) / (line._words.length - 1); space = (i != lines.length - 1) ? space : 0; tspan.style.setProperty('word-spacing', space + 'px'); } else if (this._align == 'center') { anchor = 'middle'; x = this._width / 2; } else if (this._align == 'right') { anchor = 'end'; x = this._width; } tspan.setAttribute('x', x); tspan.setAttribute('dy', i ? this._interval : '1em'); this._svg.appendChild(tspan); } this._svg.style.setProperty('text-anchor', anchor); this._show(); }
First we need to clear whatever text contents have been displayed in our placeholder
<text>
element. We're going to start from scratch to display the new
text according to the latest property or data changes. Then we clone our _lines
array; we might want to do a different layout with the same line splitting. All we
have to
do now is process each line in the cloned array and build a corresponding
<tspan>
element with the right properties. You'll notice that the rest
of our _layout()
method is not a lot of code because SVG 1.0 a lot for text
wrapping. First thing we will want to use is the text-anchor
CSS property that
will allow us to specify how the line text is spread in regards to its anchor point.
For a left-alignment we use its default value of start
which will basically
have our text positioned exactly where we said with the transform
attribute on
the <text>
element. This default value will work for both left-aligned
and justified text as text will flow naturally from left to right starting at that
point.
For centered alignment we want to use the middle
value assigned to that
property: that means the text will be spread equally on both sides of the starting
point.
Now we must make sure our starting point is translated by half of the desired box
width.
Setting text-anchor
to end
will make the text flow from
right-to-left in the case of right-to-left languages, but we have to translate our
anchor-point by the desired width of the wrapping box. In order to do those translations,
we
use the x
attribute of the <tspan>
element.
We use the dy
attribute as well: this one helps us set the line interval; it
specifies a vertical offset between this one and the previous <tspan>
element. When we are handling the first line, we set dy
differently to
1em
. Why? The position of a <text>
element is the
lower-left corner of its first rendered line. In my case, I wanted the provided
x
and y
attributes on the <text:wrap>
element
to say where it will be positioned according to its upper-right position. Setting
dy
to 1em
will have my lines starting one line down, as if there
was a previous invisible one (as 1em
is equal to whatever our a glyph's height
is).
Also in Sacré SVG |
There's one last thing we take care of in the case of justified alignment. When we
were
breaking the lines, we made sure to check out how long lines were in pixels before
they grew
too long (more than 440 in our demo). We then saved that length in the Line
object we created and pushed onto our _lines
array. It comes in handy in
justification since we're going to say what the interval is between each word, so
that our
line length is exactly 440 pixels. We compute that interval simply with (this._width -
line._width) / (line._words.length - 1)
. This value is then used to set another
great CSS property, word-spacing
, in order to control the length of a space
between two words. That's all there is to justification.
Wrapping It All Up
We've come up with a pretty neat and useful extension to SVG by using it as a 2D Graphics API. But the great thing is that it's more than an API. It's also got an XML front-end and allows us to build higher-level blocks with higher level of semantics. We will explore all of this further with XForms-related work in the coming months.