Raphael JS Text Along path - javascript

I am looking for an example or some confirmation on a concept. Looking to use Raphael JS on an app and want to be able to warp text similar to how graphic design applications such as Illustrator do.

Here's an adaptation of Chris Wilson's code, refactored as a drop-in function, with added features:
IE8 / VML mode support and Gecko/Firefox support (by defining the rotation origin - without this, IE8 and Firefox go nuts throwing the text all around the page)
A small adjustment to make text less ugly in Gecko browsers (e.g. Firefox) - without this, these increase the letter spacing arbitrarily
Support for manually defined font sizes and letter spacing, as well as dynamic 'fill the path' sizes and spacing
Support for manual kerning (fine-tuning letter spacing character by character). Text on a path often creates really ugly letter spaces; this allows you to define manual tweaks to fix these, by:
Numeric position in the string, or,
Character, or,
Character pairs (applying to instances of a character based on the preceding character, e.g. the below example tightens 'ae' pairs and widens 'rn' pairs)
JSBIN DEMO
function textOnPath( message, path, fontSize, letterSpacing, kerning, geckoKerning) {
// only message and path are required, other args are optional
// if fontSize or letterSpacing are undefined, they are calculated to fill the path
// 10% of fontSize is usually good for manual letterspacing
// Gecko, i.e. Firefox etc, inflates and alters the letter spacing
var gecko = /rv:([^\)]+)\) Gecko\/\d{8}/.test(navigator.userAgent||'') ? true : false;
var letters = [], places = [], messageLength = 0;
for (var c=0; c < message.length; c++) {
var letter = paper.text(0, 0, message[c]).attr({"text-anchor" : "middle"});
letters.push(letter);
if (kerning) {
if(gecko && geckoKerning) {
kerning = geckoKerning;
}
var character = letter.attr('text'), kern = 0;
var predecessor = letters[c-1] ? letters[c-1].attr('text') : '';
if (kerning[c]) {
kern = kerning[c];
} else if (kerning[character]) {
if( typeof kerning[character] === 'object' ) {
kern = kerning[character][predecessor] || kerning[character]['default'] || 0;
} else {
kern = kerning[character];
}
}
if(kerning['default'] ) {
kern = kern + (kerning['default'][predecessor] || 0);
}
messageLength += kern;
}
places.push(messageLength);
//spaces get a width of 0, so set min at 4px
messageLength += Math.max(4.5, letter.getBBox().width);
}
if( letterSpacing ){
if (gecko) {
letterSpacing = letterSpacing * 0.83;
}
} else {
letterSpacing = letterSpacing || path.getTotalLength() / messageLength;
}
fontSize = fontSize || 10 * letterSpacing;
for (c = 0; c < letters.length; c++) {
letters[c].attr("font-size", fontSize + "px");
p = path.getPointAtLength(places[c] * letterSpacing);
var rotate = 'R' + (p.alpha < 180 ? p.alpha + 180 : p.alpha > 360 ? p.alpha - 360 : p.alpha )+','+p.x+','+p.y;
letters[c].attr({ x: p.x, y: p.y, transform: rotate });
}
}

This isn't too difficult using path.getPointAtLength, as Kevin Nielsen suggests:
path = paper.path("M50,100c40,-50 270,50 300,0").attr("stroke", "#CCC");
message = "I want to do this in RaphaelJS";
//since not every letter is the same width, get the placement for each letter
//along the length of the string
//however, Raphael appears to group the width of letters into intervals of 4px,
//so this won't be perfect
for (; c < message.length; c += 1) {
letter = paper.text(0, 0, message[c]).attr({"text-anchor" : "start"});
letters.push(letter);
places.push(message_length);
//spaces get a width of 0, so set min at 4px
message_length += Math.max(4, letter.getBBox().width);
}
ratio = path.getTotalLength() / message_length;
fontsize = 10 * ratio;
for (c = 0; c < letters.length; c += 1) {
letters[c].attr("font-size", fontsize + "px");
p = path.getPointAtLength(places[c] * ratio);
//there does appear to be a bug in p.alpha around 180. Here's the fix
letters[c].attr({ x: p.x, y: p.y, transform: 'r' + (p.alpha < 180 ? p.alpha + 180 : p.alpha)});
}
jsFiddle

Related

Algorithm for searching a grid radially from a point

I have an object that sits at point 0,0. This object cannot share space with any other object of its type that may appear on top of it, next to it, above it, etc.. There may be more than a few of these objects present overlapping each other and i have no knowledge of where the other ones are placed until i try the collision detection method.
My thinking is that i'll use a collision detection along side a grid search. Along the lines of the photo below.
The object will first try its default best case location. If that doesn't work then it tries to the left, left-above, left-below, etc, until it has searched all the #1 positions. Then it moves onto the #2 positions and so on until it finds a place to drop the element where it won't be overlapping another.
this is the code i'm playing around with right now but it is choosing some very, very random locations for things. I'm pretty sure it isn't following the algorithm i described above.
for (let i = 0; i < 5 && this._hasCollisions(this._tagWrapper); i++) {
/**
* This algorithm explores positions inside nested boxes.
* The move algorithm behaves the following way. It goes,
* down, up, left, down, up, right * 2, repeat.
*
* For example this is how it works given the height of 5 and a width of 7
* numbers are expressed in the offset generated
* 1: 5,0 4: 5,-7 7: 5,7 10: 10,-14
* 2: -5,0 5: -5,-7 8: -5,7 11: -10,-14
* 3: 0,-7 6: 0,7 9: 0,-14
*/
// Calculate which box the collision detector is working in
// This happens every 9 iterations
let multiplier = (i / 9) + 1;
/**
* Get the x offset
*/
if (i % 3 === 0) {
// Clear the height offset on multiples of 3
xOffset = 0;
} else {
// Set the height to the multiplier
xOffset = this._tagWrapper.offsetWidth * multiplier;
}
if (i % 3 === 2) {
// Get the sequence 2, 5, 8, 11, 14, etc..
xOffset *= -1;
}
/**
* Get the y offset
*/
if (i > 2) {
// Set the width to a multiple of the multiplier and assign the existing negativeness
yOffset = this._tagWrapper.offsetHeight * multiplier * (yOffset > 0 ? 1 : -1);
}
if (i % 3 === 0) {
// Flip the sign every 3 numbers
yOffset *= -1;
}
console.log('iteration', i);
this._tagWrapper.style.top = (basePosition.y + yOffset) + 'px';
this._tagWrapper.style.left = (basePosition.x + xOffset) + 'px';
}
What is the best way to go about performing this search? I already hav
Something like this work? (most of the code is just for the visualization)
// just draw a table to visualize
var SIZE = 15;
for (var i = 0; i < SIZE; i++) {
$("#a").append("<tr>");
for (var j = 0; j < SIZE; j++) {
$("#a > tr").last().append("<td>.</td>");
}
}
// where to start searching from
var startX = 8;
var startY = 8;
function loop() {
// tell the world which grid we are on
$("#a > tr:nth-child(" + y + ") > td:nth-child(" + x + ")").css("backgroundColor", "red");
// check if done here!!! - x and y are our positions in the grid
// also do bounds checking here
if (isX) {
x = x + xDirection;
i--;
if (!i) {
// switch dimension
isX = !isX;
i = moveFor;
// switch direction
xDirection *= -1;
}
} else {
y = y + yDirection;
i--;
if (!i) {
// switch dimension
isX = !isX;
// increase the width / height we are spanning
moveFor += 1;
i = moveFor;
// switch direction
yDirection *= -1;
}
}
// jsut so that we have a nice animation
if (x > 0 && y > 0 && x <= SIZE && y <= SIZE) {
setTimeout(loop, 10)
}
}
var x = startX;
var y = startY;
var moveFor = 1;
// our step (down) counter
var i = moveFor;
var xDirection = -1;
var yDirection = -1;
// are we moving along x or y
var isX = true;
loop();
body {
font-family: monospace;
}
td {
height: 20px;
width: 20px;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<table>
<tbody id="a"></tbody>
</table>
I would suggest using distance solution
point1 has x1 and y1
point2 has x2 and y2
var d = Math.sqrt( (x2-=x1)*x2 + (y2-=y1)*y2 );
link here: Get distance between two points in canvas
Here is an implementation of scanning points in a ring around a center point. You define the center point and the distance you want to sample and it returns the list of points in clock-wise order. It is in Python not JavaScript but it is simple enough that you can translate if needed.

HTML5 Canvas, replacing colors in an image not working on some machines

I have a 2d RTS HTML5 / Javascript game. I use images to display the player's units and buildings. I provide the image and then use a script to replace certain colors in the images with other color, to get different versions of an image with different colors (so the soldier of player 1 has a red sword and the soldier of player 2 has a blue sword and so on...).
The problem is, for maybe ~20% of the users this replacing thing doesnt work and they see all units in the same (default) color. Im now wondering why this is. Heres the function i use to replayce the colors:
// returns a image with some colors replaced, specified by search and replace, which are arrays of color arrays ([[255, 255, 255], [...], ...], )
ImageTransformer.replaceColors = function(img, search, replace)
{
var canv = document.createElement('canvas');
canv.height = img.height;
canv.width = img.width
var ctx = canv.getContext('2d');
ctx.drawImage(img, 0, 0);
var imgData = ctx.getImageData(0, 0, canv.width, canv.height);
for(var i = 0; i < imgData.data.length; i += 4)
for(var k = 0; k < search.length; k++)
if(imgData.data[i] == search[k][0] && imgData.data[i + 1] == search[k][1] && imgData.data[i + 2] == search[k][2])
{
imgData.data[i] = replace[k][0];
imgData.data[i + 1] = replace[k][1];
imgData.data[i + 2] = replace[k][2];
}
ctx.putImageData(imgData, 0, 0);
return canv;
}
Browsers may or may not apply a gamma to the image prior to drawing them, the intent is to have more natural colors (...).
I bet this is the Browsers which apply a gama that fool your algorithm.
Rather than test for strict equality, you might use a color distance, and decide of a threshold to decide wether to switch or not :
var imgData = ctx.getImageData(0, 0, canv.width, canv.height);
var data = imgData.data, length = imgData.data.length ;
for(var k = 0; k < search.length; k++) {
var thisCol = search[k];
for(var i = 0; i < length; i += 4) {
var colDist = Math.abs(data[i] - thisCol[0] )
+ Math.abs(data[i+1] - thisCol[1] )
+ Math.abs(data[i+2] - thisCol[2] );
if( colDist < 5 )
{
data[i] = thisCol[0];
data[i + 1] = thisCol[1];
data[i + 2] = thisCol[2];
}
}
}
ctx.putImageData(imgData, 0, 0);
return canv;
(here i used as distance the sum of absolute differences in between r,g,b ; as #MarkE suggest, you can choose others, euclidian being this:
var colDist = sq(data[i] - thisCol[0] )
+ sq(data[i+1] - thisCol[1] )
+ sq(data[i+2] - thisCol[2] );
// notice this is the squared euclidian distance.
// whith function sq(x) { return x*x }
test several pictures / distances, and see what fits.
test several threshold also.
).

hough transform - javascript - node.js

So, i'm trying to implement hough transform, this version is 1-dimensional (its for all dims reduced to 1 dim optimization) version based on the minor properties.
Enclosed is my code, with a sample image... input and output.
Obvious question is what am i doing wrong. I've tripled check my logic and code and it looks good also my parameters. But obviously i'm missing on something.
Notice that the red pixels are supposed to be ellipses centers , while the blue pixels are edges to be removed (belong to the ellipse that conform to the mathematical equations).
also, i'm not interested in openCV / matlab / ocatve / etc.. usage (nothing against them).
Thank you very much!
var fs = require("fs"),
Canvas = require("canvas"),
Image = Canvas.Image;
var LEAST_REQUIRED_DISTANCE = 40, // LEAST required distance between 2 points , lets say smallest ellipse minor
LEAST_REQUIRED_ELLIPSES = 6, // number of found ellipse
arr_accum = [],
arr_edges = [],
edges_canvas,
xy,
x1y1,
x2y2,
x0,
y0,
a,
alpha,
d,
b,
max_votes,
cos_tau,
sin_tau_sqr,
f,
new_x0,
new_y0,
any_minor_dist,
max_minor,
i,
found_minor_in_accum,
arr_edges_len,
hough_file = 'sample_me2.jpg',
edges_canvas = drawImgToCanvasSync(hough_file); // make sure everything is black and white!
arr_edges = getEdgesArr(edges_canvas);
arr_edges_len = arr_edges.length;
var hough_canvas_img_data = edges_canvas.getContext('2d').getImageData(0, 0, edges_canvas.width,edges_canvas.height);
for(x1y1 = 0; x1y1 < arr_edges_len ; x1y1++){
if (arr_edges[x1y1].x === -1) { continue; }
for(x2y2 = 0 ; x2y2 < arr_edges_len; x2y2++){
if ((arr_edges[x2y2].x === -1) ||
(arr_edges[x2y2].x === arr_edges[x1y1].x && arr_edges[x2y2].y === arr_edges[x1y1].y)) { continue; }
if (distance(arr_edges[x1y1],arr_edges[x2y2]) > LEAST_REQUIRED_DISTANCE){
x0 = (arr_edges[x1y1].x + arr_edges[x2y2].x) / 2;
y0 = (arr_edges[x1y1].y + arr_edges[x2y2].y) / 2;
a = Math.sqrt((arr_edges[x1y1].x - arr_edges[x2y2].x) * (arr_edges[x1y1].x - arr_edges[x2y2].x) + (arr_edges[x1y1].y - arr_edges[x2y2].y) * (arr_edges[x1y1].y - arr_edges[x2y2].y)) / 2;
alpha = Math.atan((arr_edges[x2y2].y - arr_edges[x1y1].y) / (arr_edges[x2y2].x - arr_edges[x1y1].x));
for(xy = 0 ; xy < arr_edges_len; xy++){
if ((arr_edges[xy].x === -1) ||
(arr_edges[xy].x === arr_edges[x2y2].x && arr_edges[xy].y === arr_edges[x2y2].y) ||
(arr_edges[xy].x === arr_edges[x1y1].x && arr_edges[xy].y === arr_edges[x1y1].y)) { continue; }
d = distance({x: x0, y: y0},arr_edges[xy]);
if (d > LEAST_REQUIRED_DISTANCE){
f = distance(arr_edges[xy],arr_edges[x2y2]); // focus
cos_tau = (a * a + d * d - f * f) / (2 * a * d);
sin_tau_sqr = (1 - cos_tau * cos_tau);//Math.sqrt(1 - cos_tau * cos_tau); // getting sin out of cos
b = (a * a * d * d * sin_tau_sqr ) / (a * a - d * d * cos_tau * cos_tau);
b = Math.sqrt(b);
b = parseInt(b.toFixed(0));
d = parseInt(d.toFixed(0));
if (b > 0){
found_minor_in_accum = arr_accum.hasOwnProperty(b);
if (!found_minor_in_accum){
arr_accum[b] = {f: f, cos_tau: cos_tau, sin_tau_sqr: sin_tau_sqr, b: b, d: d, xy: xy, xy_point: JSON.stringify(arr_edges[xy]), x0: x0, y0: y0, accum: 0};
}
else{
arr_accum[b].accum++;
}
}// b
}// if2 - LEAST_REQUIRED_DISTANCE
}// for xy
max_votes = getMaxMinor(arr_accum);
// ONE ellipse has been detected
if (max_votes != null &&
(max_votes.max_votes > LEAST_REQUIRED_ELLIPSES)){
// output ellipse details
new_x0 = parseInt(arr_accum[max_votes.index].x0.toFixed(0)),
new_y0 = parseInt(arr_accum[max_votes.index].y0.toFixed(0));
setPixel(hough_canvas_img_data,new_x0,new_y0,255,0,0,255); // Red centers
// remove the pixels on the detected ellipse from edge pixel array
for (i=0; i < arr_edges.length; i++){
any_minor_dist = distance({x:new_x0, y: new_y0}, arr_edges[i]);
any_minor_dist = parseInt(any_minor_dist.toFixed(0));
max_minor = b;//Math.max(b,arr_accum[max_votes.index].d); // between the max and the min
// coloring in blue the edges we don't need
if (any_minor_dist <= max_minor){
setPixel(hough_canvas_img_data,arr_edges[i].x,arr_edges[i].y,0,0,255,255);
arr_edges[i] = {x: -1, y: -1};
}// if
}// for
}// if - LEAST_REQUIRED_ELLIPSES
// clear accumulated array
arr_accum = [];
}// if1 - LEAST_REQUIRED_DISTANCE
}// for x2y2
}// for xy
edges_canvas.getContext('2d').putImageData(hough_canvas_img_data, 0, 0);
writeCanvasToFile(edges_canvas, __dirname + '/hough.jpg', function() {
});
function getMaxMinor(accum_in){
var max_votes = -1,
max_votes_idx,
i,
accum_len = accum_in.length;
for(i in accum_in){
if (accum_in[i].accum > max_votes){
max_votes = accum_in[i].accum;
max_votes_idx = i;
} // if
}
if (max_votes > 0){
return {max_votes: max_votes, index: max_votes_idx};
}
return null;
}
function distance(point_a,point_b){
return Math.sqrt((point_a.x - point_b.x) * (point_a.x - point_b.x) + (point_a.y - point_b.y) * (point_a.y - point_b.y));
}
function getEdgesArr(canvas_in){
var x,
y,
width = canvas_in.width,
height = canvas_in.height,
pixel,
edges = [],
ctx = canvas_in.getContext('2d'),
img_data = ctx.getImageData(0, 0, width, height);
for(x = 0; x < width; x++){
for(y = 0; y < height; y++){
pixel = getPixel(img_data, x,y);
if (pixel.r !== 0 &&
pixel.g !== 0 &&
pixel.b !== 0 ){
edges.push({x: x, y: y});
}
} // for
}// for
return edges
} // getEdgesArr
function drawImgToCanvasSync(file) {
var data = fs.readFileSync(file)
var canvas = dataToCanvas(data);
return canvas;
}
function dataToCanvas(imagedata) {
img = new Canvas.Image();
img.src = new Buffer(imagedata, 'binary');
var canvas = new Canvas(img.width, img.height);
var ctx = canvas.getContext('2d');
ctx.patternQuality = "best";
ctx.drawImage(img, 0, 0, img.width, img.height,
0, 0, img.width, img.height);
return canvas;
}
function writeCanvasToFile(canvas, file, callback) {
var out = fs.createWriteStream(file)
var stream = canvas.createPNGStream();
stream.on('data', function(chunk) {
out.write(chunk);
});
stream.on('end', function() {
callback();
});
}
function setPixel(imageData, x, y, r, g, b, a) {
index = (x + y * imageData.width) * 4;
imageData.data[index+0] = r;
imageData.data[index+1] = g;
imageData.data[index+2] = b;
imageData.data[index+3] = a;
}
function getPixel(imageData, x, y) {
index = (x + y * imageData.width) * 4;
return {
r: imageData.data[index+0],
g: imageData.data[index+1],
b: imageData.data[index+2],
a: imageData.data[index+3]
}
}
It seems you try to implement the algorithm of Yonghong Xie; Qiang Ji (2002). A new efficient ellipse detection method 2. p. 957.
Ellipse removal suffers from several bugs
In your code, you perform the removal of found ellipse (step 12 of the original paper's algorithm) by resetting coordinates to {-1, -1}.
You need to add:
`if (arr_edges[x1y1].x === -1) break;`
at the end of the x2y2 block. Otherwise, the loop will consider -1, -1 as a white point.
More importantly, your algorithm consists in erasing every point which distance to the center is smaller than b. b supposedly is the minor axis half-length (per the original algorithm). But in your code, variable b actually is the latest (and not most frequent) half-length, and you erase points with a distance lower than b (instead of greater, since it's the minor axis). In other words, you clear all points inside a circle with a distance lower than latest computed axis.
Your sample image can actually be processed with a clearing of all points inside a circle with a distance lower than selected major axis with:
max_minor = arr_accum[max_votes.index].d;
Indeed, you don't have overlapping ellipses and they are spread enough. Please consider a better algorithm for overlapping or closer ellipses.
The algorithm mixes major and minor axes
Step 6 of the paper reads:
For each third pixel (x, y), if the distance between (x, y) and (x0,
y0) is greater than the required least distance for a pair of pixels
to be considered then carry out the following steps from (7) to (9).
This clearly is an approximation. If you do so, you will end up considering points further than the minor axis half length, and eventually on the major axis (with axes swapped). You should make sure the distance between the considered point and the tested ellipse center is smaller than currently considered major axis half-length (condition should be d <= a). This will help with the ellipse erasing part of the algorithm.
Also, if you also compare with the least distance for a pair of pixels, as per the original paper, 40 is too large for the smaller ellipse in your picture. The comment in your code is wrong, it should be at maximum half the smallest ellipse minor axis half-length.
LEAST_REQUIRED_ELLIPSES is too small
This parameter is also misnamed. It is the minimum number of votes an ellipse should get to be considered valid. Each vote corresponds to a pixel. So a value of 6 means that only 6+2 pixels make an ellipse. Since pixels coordinates are integers and you have more than 1 ellipse in your picture, the algorithm might detect ellipses that are not, and eventually clear edges (especially when combined with the buggy ellipse erasing algorithm). Based on tests, a value of 100 will find four of the five ellipses of your picture, while 80 will find them all. Smaller values will not find the proper centers of the ellipses.
Sample image is not black & white
Despite the comment, sample image is not exactly black and white. You should convert it or apply some threshold (e.g. RGB values greater than 10 instead of simply different form 0).
Diff of minimum changes to make it work is available here:
https://gist.github.com/pguyot/26149fec29ffa47f0cfb/revisions
Finally, please note that parseInt(x.toFixed(0)) could be rewritten Math.floor(x), and you probably want to not truncate all floats like this, but rather round them, and proceed where needed: the algorithm to erase the ellipse from the picture would benefit from non truncated values for the center coordinates. This code definitely could be improved further, for example it currently computes the distance between points x1y1 and x2y2 twice.

Selecting individual letters in .print with Raphael 2.1

I tried to do what this Raphael tutorial [ http://www.html5rocks.com/en/tutorials/raphael/intro/ ] does and select individual letters from a string printed with .print, but no joy
I have generated the font with cufon, and replaced Cufon.registerFont with Raphael.registerFont
var paper = Raphael( '#div', 500, 500 ),
label = paper.print( xCenter, yCenter, 'blah', paper.getFont("CelliniProMedium"), 54 );
label[1].attr( 'fill', 'red');
causes an error because label is just a path not an array of paths.
What gives?
thanks in advance
Yeah, this is definitely a change in behavior between 1.4 and 2.0 -- and it's a bit of functionality that came in handy in more than one situation.
On the other hand, replicating the array result functionality is easy to do by extending Raphael 2.0...
Raphael.fn.printArray = function printArray( x, y, string, font, size, letter_spacing, line_height )
{
var result = [];
var cx = x, cy = y;
size = size || 16;
letter_spacing = letter_spacing || 0.2;
line_height = line_height || 1.5;
for ( var i = 0; i < string.length; i++ )
{
if ( string[i] == " " )
{
cx += size;
continue;
}
else if ( string[i] == "\n" )
{
cx = x;
cy += size * line_height;
continue;
}
var glyph = this.print( 0, 0, string[i], font, size ).attr( { opacity: 0 } );
var glyphBox = glyph.getBBox();
glyph.attr( { transform: "T" + cx + "," + cy, opacity: 1 } );
cx += glyphBox.width + ( size * letter_spacing );
result.push( glyph );
}
return result;
}
This isn't perfect code, but with a little refinement it could easily fill the gap.
so it looks like it's either a bug or a feature with 2.1 - I got 1.4 from GitHub and it works as expected. Leaving question open for a short while in case anyone can shed any more lights on this, as I think it may be useful to others.

Baking transforms into SVG Path Element commands

tl;dr summary: Give me the resources or help fix the below code to transform path commands for SVG <path> elements by an arbitrary matrix.
details:
I'm writing a library to convert any arbitrary SVG shape into a <path> element. I have it working when there are no transform="..." elements in the hierarchy, but now I want to bake the local transform of the object into the path data commands themselves.
This is mostly working (code below) when dealing with the simple moveto/lineto commands. However, I'm not sure of the appropriate way to transform the bezier handles or arcTo parameters.
For example, I am able to convert this rounded rectangle to a <path>:
<rect x="10" y="30" rx="10" ry="20" width="80" height="70" />
--> <path d=​"M20,30 L80,30 A10,20,0,0,1,90,50 L90,80 A10,20,0,0,1,80,100
L20,100 A10,20,0,0,1,10,80 L10,50 A10,20,0,0,1,20,30" />
And I get a valid result when transforming without any round corners:
<rect x="10" y="30" width="80" height="70"
transform="translate(-200,0) scale(1.5) rotate(50)" />
--> <path d=​"M10,30 L90,30 L90,100 L10,100 L10,30" />
However, transforming only the x/y coords of the elliptical arc commands yields amusing results:
The dotted line is the actual transformed rect, the green fill is my path.
Following is the code I have so far (slightly pared-down). I also have a test page where I'm testing various shapes. Please help me determine how to properly transform the elliptical arc and various other bezier commands given an arbitrary transformation matrix.
function flattenToPaths(el,transform,svg){
if (!svg) svg=el; while(svg && svg.tagName!='svg') svg=svg.parentNode;
var doc = el.ownerDocument;
var svgNS = svg.getAttribute('xmlns');
// Identity transform if nothing passed in
if (!transform) transform= svg.createSVGMatrix();
// Calculate local transform matrix for the object
var localMatrix = svg.createSVGMatrix();
for (var xs=el.transform.baseVal,i=xs.numberOfItems-1;i>=0;--i){
localMatrix = xs.getItem(i).matrix.multiply(localMatrix);
}
// Transform the local transform by whatever was recursively passed in
transform = transform.multiply(localMatrix);
var path = doc.createElementNS(svgNS,'path');
switch(el.tagName){
case 'rect':
path.setAttribute('stroke',el.getAttribute('stroke'));
var x = el.getAttribute('x')*1, y = el.getAttribute('y')*1,
w = el.getAttribute('width')*1, h = el.getAttribute('height')*1,
rx = el.getAttribute('rx')*1, ry = el.getAttribute('ry')*1;
if (rx && !el.hasAttribute('ry')) ry=rx;
else if (ry && !el.hasAttribute('rx')) rx=ry;
if (rx>w/2) rx=w/2;
if (ry>h/2) ry=h/2;
path.setAttribute('d',
'M'+(x+rx)+','+y+
'L'+(x+w-rx)+','+y+
((rx||ry) ? ('A'+rx+','+ry+',0,0,'+(rx*ry<0?0:1)+','+(x+w)+','+(y+ry)) : '') +
'L'+(x+w)+','+(y+h-ry)+
((rx||ry) ? ('A'+rx+','+ry+',0,0,'+(rx*ry<0?0:1)+','+(x+w-rx)+','+(y+h)) : '')+
'L'+(x+rx)+','+(y+h)+
((rx||ry) ? ('A'+rx+','+ry+',0,0,'+(rx*ry<0?0:1)+','+x+','+(y+h-ry)) : '')+
'L'+x+','+(y+ry)+
((rx||ry) ? ('A'+rx+','+ry+',0,0,'+(rx*ry<0?0:1)+','+(x+rx)+','+y) : '')
);
break;
case 'circle':
var cx = el.getAttribute('cx')*1, cy = el.getAttribute('cy')*1,
r = el.getAttribute('r')*1, r0 = r/2+','+r/2;
path.setAttribute('d','M'+cx+','+(cy-r)+' A'+r0+',0,0,0,'+cx+','+(cy+r)+' '+r0+',0,0,0,'+cx+','+(cy-r) );
break;
case 'ellipse':
var cx = el.getAttribute('cx')*1, cy = el.getAttribute('cy')*1,
rx = el.getAttribute('rx')*1, ry = el.getAttribute('ry')*1;
path.setAttribute('d','M'+cx+','+(cy-ry)+' A'+rx+','+ry+',0,0,0,'+cx+','+(cy+ry)+' '+rx+','+ry+',0,0,0,'+cx+','+(cy-ry) );
break;
case 'line':
var x1=el.getAttribute('x1')*1, y1=el.getAttribute('y1')*1,
x2=el.getAttribute('x2')*1, y2=el.getAttribute('y2')*1;
path.setAttribute('d','M'+x1+','+y1+'L'+x2+','+y2);
break;
case 'polyline':
case 'polygon':
for (var i=0,l=[],pts=el.points,len=pts.numberOfItems;i<len;++i){
var p = pts.getItem(i);
l[i] = p.x+','+p.y;
}
path.setAttribute('d',"M"+l.shift()+"L"+l.join(' ') + (el.tagName=='polygon') ? 'z' : '');
break;
case 'path':
path = el.cloneNode(false);
break;
}
// Convert local space by the transform matrix
var x,y;
var pt = svg.createSVGPoint();
var setXY = function(x,y,xN,yN){
pt.x = x; pt.y = y;
pt = pt.matrixTransform(transform);
if (xN) seg[xN] = pt.x;
if (yN) seg[yN] = pt.y;
};
// Extract rotation and scale from the transform
var rotation = Math.atan2(transform.b,transform.d)*180/Math.PI;
var sx = Math.sqrt(transform.a*transform.a+transform.c*transform.c);
var sy = Math.sqrt(transform.b*transform.b+transform.d*transform.d);
// FIXME: Must translate any Horizontal or Vertical lineto commands into absolute moveto
for (var segs=path.pathSegList,c=segs.numberOfItems,i=0;i<c;++i){
var seg = segs.getItem(i);
// Odd-numbered path segments are all relative
// http://www.w3.org/TR/SVG/paths.html#InterfaceSVGPathSeg
var isRelative = (seg.pathSegType%2==1);
var hasX = seg.x != null;
var hasY = seg.y != null;
if (hasX) x = isRelative ? x+seg.x : seg.x;
if (hasY) y = isRelative ? y+seg.y : seg.y;
if (hasX || hasY) setXY( x, y, hasX && 'x', hasY && 'y' );
if (seg.x1 != null) setXY( seg.x1, seg.y1, 'x1', 'y1' );
if (seg.x2 != null) setXY( seg.x2, seg.y2, 'x2', 'y2' );
if (seg.angle != null){
seg.angle += rotation;
seg.r1 *= sx; // FIXME; only works for uniform scale
seg.r2 *= sy; // FIXME; only works for uniform scale
}
}
return path;
}
I have made a general SVG flattener flatten.js, that supports all shapes and path commands:
https://gist.github.com/timo22345/9413158
Basic usage: flatten(document.getElementById('svg'));
What it does: Flattens elements (converts elements to paths and flattens transformations).
If the argument element (whose id is above 'svg') has children, or it's descendants has children,
these children elements are flattened also.
What can be flattened: entire SVG document, individual shapes (path, circle, ellipse etc.) and groups. Nested groups are handled automatically.
How about attributes? All attributes are copied. Only arguments that are not valid in path element, are dropped (eg. r, rx, ry, cx, cy), but they are not needed anymore. Also transform attribute is dropped, because transformations are flattened to path commands.
If you want to modify path coordinates using non-affine methods (eg. perspective distort),
you can convert all segments to cubic curves using:
flatten(document.getElementById('svg'), true);
There are also arguments 'toAbsolute' (convert coordinates to absolute) and 'dec',
number of digits after decimal separator.
Extreme path and shape tester: https://jsfiddle.net/fjm9423q/embedded/result/
Basic usage example: http://jsfiddle.net/nrjvmqur/embedded/result/
CONS: text element is not working. It could be my next goal.
If every object (circles etc) are converted first to paths, then taking transforms into account is rather easy. I made a testbed ( http://jsbin.com/oqojan/73 ) where you can test the functionality. The testbed creates random path commands and applies random transforms to paths and then flattens transforms. Of course in reality the path commands and transforms are not random, but for testing accuracy it is fine.
There is a function flatten_transformations(), which makes the main task:
function flatten_transformations(path_elem, normalize_path, to_relative, dec) {
// Rounding coordinates to dec decimals
if (dec || dec === 0) {
if (dec > 15) dec = 15;
else if (dec < 0) dec = 0;
}
else dec = false;
function r(num) {
if (dec !== false) return Math.round(num * Math.pow(10, dec)) / Math.pow(10, dec);
else return num;
}
// For arc parameter rounding
var arc_dec = (dec !== false) ? 6 : false;
arc_dec = (dec && dec > 6) ? dec : arc_dec;
function ra(num) {
if (arc_dec !== false) return Math.round(num * Math.pow(10, arc_dec)) / Math.pow(10, arc_dec);
else return num;
}
var arr;
//var pathDOM = path_elem.node;
var pathDOM = path_elem;
var d = pathDOM.getAttribute("d").trim();
// If you want to retain current path commans, set normalize_path to false
if (!normalize_path) { // Set to false to prevent possible re-normalization.
arr = Raphael.parsePathString(d); // str to array
arr = Raphael._pathToAbsolute(arr); // mahvstcsqz -> uppercase
}
// If you want to modify path data using nonAffine methods,
// set normalize_path to true
else arr = Raphael.path2curve(d); // mahvstcsqz -> MC
var svgDOM = pathDOM.ownerSVGElement;
// Get the relation matrix that converts path coordinates
// to SVGroot's coordinate space
var matrix = pathDOM.getTransformToElement(svgDOM);
// The following code can bake transformations
// both normalized and non-normalized data
// Coordinates have to be Absolute in the following
var i = 0,
j, m = arr.length,
letter = "",
x = 0,
y = 0,
point, newcoords = [],
pt = svgDOM.createSVGPoint(),
subpath_start = {};
subpath_start.x = "";
subpath_start.y = "";
for (; i < m; i++) {
letter = arr[i][0].toUpperCase();
newcoords[i] = [];
newcoords[i][0] = arr[i][0];
if (letter == "A") {
x = arr[i][6];
y = arr[i][7];
pt.x = arr[i][6];
pt.y = arr[i][7];
newcoords[i] = arc_transform(arr[i][4], arr[i][5], arr[i][6], arr[i][4], arr[i][5], pt, matrix);
// rounding arc parameters
// x,y are rounded normally
// other parameters at least to 5 decimals
// because they affect more than x,y rounding
newcoords[i][7] = ra(newcoords[i][8]); //rx
newcoords[i][9] = ra(newcoords[i][10]); //ry
newcoords[i][11] = ra(newcoords[i][12]); //x-axis-rotation
newcoords[i][6] = r(newcoords[i][6]); //x
newcoords[i][7] = r(newcoords[i][7]); //y
}
else if (letter != "Z") {
// parse other segs than Z and A
for (j = 1; j < arr[i].length; j = j + 2) {
if (letter == "V") y = arr[i][j];
else if (letter == "H") x = arr[i][j];
else {
x = arr[i][j];
y = arr[i][j + 1];
}
pt.x = x;
pt.y = y;
point = pt.matrixTransform(matrix);
newcoords[i][j] = r(point.x);
newcoords[i][j + 1] = r(point.y);
}
}
if ((letter != "Z" && subpath_start.x == "") || letter == "M") {
subpath_start.x = x;
subpath_start.y = y;
}
if (letter == "Z") {
x = subpath_start.x;
y = subpath_start.y;
}
if (letter == "V" || letter == "H") newcoords[i][0] = "L";
}
if (to_relative) newcoords = Raphael.pathToRelative(newcoords);
newcoords = newcoords.flatten().join(" ").replace(/\s*([A-Z])\s*/gi, "$1").replace(/\s*([-])/gi, "$1");
return newcoords;
} // function flatten_transformations​​​​​
// Helper tool to piece together Raphael's paths into strings again
Array.prototype.flatten || (Array.prototype.flatten = function() {
return this.reduce(function(a, b) {
return a.concat('function' === typeof b.flatten ? b.flatten() : b);
}, []);
});
The code uses Raphael.pathToRelative(), Raphael._pathToAbsolute() and Raphael.path2curve(). The Raphael.path2curve() is bugfixed version.
If flatten_transformations() is called using argument normalize_path=true, then all commands are converted to Cubics and everything is fine. And the code can be simplified by removing if (letter == "A") { ... } and also removing handling of H, V and Z. The simplified version can be something like this.
But because someone may want to only bake transformations and not to make All Segs -> Cubics normalization, I added there a possibility to this. So, if you want to flatten transformations with normalize_path=false, this means that Elliptical Arc parameters have to be flattened also and it's not possible to handle them by simply applying matrix to coordinates. Two radiis (rx ry), x-axis-rotation, large-arc-flag and sweep-flag have to handle separately. So the following function can flatten transformations of Arcs. The matrix parameter is a relation matrix which comes from is used already in flatten_transformations().
// Origin: http://devmaster.net/forums/topic/4947-transforming-an-ellipse/
function arc_transform(a_rh, a_rv, a_offsetrot, large_arc_flag, sweep_flag, endpoint, matrix, svgDOM) {
function NEARZERO(B) {
if (Math.abs(B) < 0.0000000000000001) return true;
else return false;
}
var rh, rv, rot;
var m = []; // matrix representation of transformed ellipse
var s, c; // sin and cos helpers (the former offset rotation)
var A, B, C; // ellipse implicit equation:
var ac, A2, C2; // helpers for angle and halfaxis-extraction.
rh = a_rh;
rv = a_rv;
a_offsetrot = a_offsetrot * (Math.PI / 180); // deg->rad
rot = a_offsetrot;
s = parseFloat(Math.sin(rot));
c = parseFloat(Math.cos(rot));
// build ellipse representation matrix (unit circle transformation).
// the 2x2 matrix multiplication with the upper 2x2 of a_mat is inlined.
m[0] = matrix.a * +rh * c + matrix.c * rh * s;
m[1] = matrix.b * +rh * c + matrix.d * rh * s;
m[2] = matrix.a * -rv * s + matrix.c * rv * c;
m[3] = matrix.b * -rv * s + matrix.d * rv * c;
// to implict equation (centered)
A = (m[0] * m[0]) + (m[2] * m[2]);
C = (m[1] * m[1]) + (m[3] * m[3]);
B = (m[0] * m[1] + m[2] * m[3]) * 2.0;
// precalculate distance A to C
ac = A - C;
// convert implicit equation to angle and halfaxis:
if (NEARZERO(B)) {
a_offsetrot = 0;
A2 = A;
C2 = C;
} else {
if (NEARZERO(ac)) {
A2 = A + B * 0.5;
C2 = A - B * 0.5;
a_offsetrot = Math.PI / 4.0;
} else {
// Precalculate radical:
var K = 1 + B * B / (ac * ac);
// Clamp (precision issues might need this.. not likely, but better save than sorry)
if (K < 0) K = 0;
else K = Math.sqrt(K);
A2 = 0.5 * (A + C + K * ac);
C2 = 0.5 * (A + C - K * ac);
a_offsetrot = 0.5 * Math.atan2(B, ac);
}
}
// This can get slightly below zero due to rounding issues.
// it's save to clamp to zero in this case (this yields a zero length halfaxis)
if (A2 < 0) A2 = 0;
else A2 = Math.sqrt(A2);
if (C2 < 0) C2 = 0;
else C2 = Math.sqrt(C2);
// now A2 and C2 are half-axis:
if (ac <= 0) {
a_rv = A2;
a_rh = C2;
} else {
a_rv = C2;
a_rh = A2;
}
// If the transformation matrix contain a mirror-component
// winding order of the ellise needs to be changed.
if ((matrix.a * matrix.d) - (matrix.b * matrix.c) < 0) {
if (!sweep_flag) sweep_flag = 1;
else sweep_flag = 0;
}
// Finally, transform arc endpoint. This takes care about the
// translational part which we ignored at the whole math-showdown above.
endpoint = endpoint.matrixTransform(matrix);
// Radians back to degrees
a_offsetrot = a_offsetrot * 180 / Math.PI;
var r = ["A", a_rh, a_rv, a_offsetrot, large_arc_flag, sweep_flag, endpoint.x, endpoint.y];
return r;
}
OLD EXAMPLE:
I made an example that has a path with segments M Q A A Q M, which has transformations applied. The path is inside g that also has trans applied. And to make very sure this g is inside another g which has different transformations applied. And the code can:
A) First normalize those all path segments (thanks to Raphaël's path2curve, to which I made a bug fix, and after this fix all possible path segment combinations worked finally: http://jsbin.com/oqojan/42. The original Raphaël 2.1.0 has buggy behavior as you can see here, if not click paths few times to generate new curves.)
B) Then flatten transformations using native functions getTransformToElement(), createSVGPoint() and matrixTransform().
The only one that lacks is the way to convert Circles, Rectangles and Polygons to path commands, but as far as I know, you have an excellent code for it.
This is an updated log of any forward progress I am making as an 'answer', to help inform others; if I somehow solve the problem on my own, I'll just accept this.
Update 1: I've got the absolute arcto command working perfectly except in cases of non-uniform scale. Here were the additions:
// Extract rotation and scale from the transform
var rotation = Math.atan2(transform.b,transform.d)*180/Math.PI;
var sx = Math.sqrt(transform.a*transform.a+transform.c*transform.c);
var sy = Math.sqrt(transform.b*transform.b+transform.d*transform.d);
//inside the processing of segments
if (seg.angle != null){
seg.angle += rotation;
// FIXME; only works for uniform scale
seg.r1 *= sx;
seg.r2 *= sy;
}
Thanks to this answer for a simpler extraction method than I was using, and for the math for extracting non-uniform scale.
As long as you translate all coordinates to absolute coordinates, all béziers will work just fine; there is nothing magical about the their handles. As for the elliptical arc commands, the only general solution (handling non-uniform scaling, as you point out, which the arc command can not represent, in the general case) is to first convert them to their bézier approximations.
https://github.com/johan/svg-js-utils/blob/df605f3e21cc7fcd2d604eb318fb2466fd6d63a7/paths.js#L56..L113 (uses absolutizePath in the same file, a straight port of your Convert SVG Path to Absolute Commands hack) does the former, but not yet the latter.
How to best approximate a geometrical arc with a Bezier curve? links the math for converting arcs to béziers (one bézier segment per 0 < α <= π/2 arc segment); this paper shows the equations at the end of the page (its prettier pdf rendition has it at the end of section 3.4.1).

Categories

Resources