Simple Text Wrapping
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.
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:
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.
|
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.
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.
XML.com Copyright © 1998-2006 O'Reilly Media, Inc.