4 |
5 |
6 | QUnit Tests for svg-optimiser.js
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | SVG-optimiser.js
2 | ================
3 |
4 | An online SVG optimiser using Javascript and jQuery
5 |
6 | ##Using optimise-functions.js and SVG-elements.js
7 |
8 | ###Create an SVG_Root object
9 | The `SVG_Root` object is what parses the SVG and allows you to access the optimisation functions. You can pass it either a complete SVG or an SVG element (which can have child elements).
10 |
11 | You can pass it a string with:
12 | `var SVGObject = SVG_Root('');`
13 |
14 | Or a JQuery object with:
15 | `var SVGObject = SVG_Root($('#my-svg'));`
16 |
17 | ###Optimise the SVG
18 | Optimisation is done with: `svg.optimise();`
19 |
20 | There are many options which I will have to write about at some point.
21 |
22 | ###Write the SVG
23 | You can get the SVG as a string with:
24 | `SVGObject.write();`
25 |
26 | Or as a DOM element with:
27 | `SVGObject.createSVGObject();`
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 Peter Collingridge
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/scripts/SVG-data.js:
--------------------------------------------------------------------------------
1 | // Information about SVGs
2 |
3 | // We can remove any style given a default value
4 | var defaultStyles = {
5 | 'clip': 'auto',
6 | 'clip-path': 'none',
7 | 'clip-rule': 'nonzero',
8 | 'cursor': 'auto',
9 | 'display': 'inline',
10 | 'visibility': 'visible',
11 | 'opacity': '1',
12 | 'enable-background': 'accumulate',
13 | 'fill': '#000',
14 | 'fill-opacity': 1,
15 | 'fill-rule': 'nonzero',
16 | 'marker': 'none',
17 | 'marker-start': 'none',
18 | 'marker-mid': 'none',
19 | 'marker-end': 'none',
20 | 'stroke': 'none',
21 | 'stroke-width': 1,
22 | 'stroke-opacity': 1,
23 | 'stroke-miterlimit': 4,
24 | 'stroke-linecap': 'butt',
25 | 'stroke-linejoin': 'miter',
26 | 'stroke-dasharray': 'none',
27 | 'stroke-dashoffset': 0,
28 | 'stop-opacity': 1,
29 | 'font-anchor': 'start',
30 | 'font-style': 'normal',
31 | 'font-weight': 'normal',
32 | 'font-stretch': 'normal',
33 | 'font-variant': 'normal',
34 | 'text-anchor': 'start',
35 | 'writing-mode': 'lr-tb',
36 | 'pointer-events': 'visiblePainted'
37 | };
38 |
39 | // Attributes that can probably be removed
40 | var nonEssentialStyles = {
41 | 'color' : true,
42 | 'display' : true,
43 | 'overflow' : true,
44 | 'fill-rule' : true,
45 | 'clip-rule' : true,
46 | 'nodetypes' : true,
47 | 'stroke-miterlimit' : true,
48 | 'enable-background': true,
49 | 'baseProfile': true,
50 | 'version': true
51 | };
52 |
53 | // Attributes that are required otherwise no shape is drawn
54 | var essentialAttributes = {
55 | 'path': ['d'],
56 | 'polygon': ['points'],
57 | 'polyline': ['points'],
58 | 'rect': ['width', 'height'],
59 | 'circle': ['r'],
60 | 'ellipse': ['r'],
61 | };
62 |
63 | // Attribute which determine the size or position of elements
64 | // The default value of these is 0
65 | var positionAttributes = [
66 | 'x', 'y', 'width', 'height', 'rx', 'ry',
67 | 'cx', 'cy', 'r',
68 | 'x1', 'x2', 'y1', 'y2'
69 | ];
--------------------------------------------------------------------------------
/test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SVG optimisation
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
SVG optimiser
16 |
17 |
18 |
Upload
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
Optimise
34 |
35 |
36 |
37 |
38 |
Original
39 |
40 |
41 |
42 |
43 |
44 |
45 |
Optimised
46 |
47 |
48 |
49 |
50 |
Options
51 |
52 |
53 |
54 |
55 |
Output
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/scripts/interface.js:
--------------------------------------------------------------------------------
1 | // http://stackoverflow.com/questions/6507293/convert-xml-to-string-with-jquery
2 | function xmlToString(xmlData) {
3 | var xmlString;
4 | if (window.ActiveXObject){
5 | // IE
6 | xmlString = xmlData.xml;
7 | } else {
8 | // Mozilla, Firefox, Opera, etc.
9 | xmlString = (new XMLSerializer()).serializeToString(xmlData);
10 | }
11 | return xmlString;
12 | }
13 |
14 | // Parse an SVG string as XML
15 | function stringToXML(svgString) {
16 | // Replace any leading whitespace which will mess up XML parsing
17 | svgString = svgString.replace(/^[\s\n]*/, "");
18 |
19 | if (!svgString) { return; }
20 |
21 | // Parse SVG as XML
22 | var svgDoc;
23 | try {
24 | svgDoc = $.parseXML(svgString);
25 | } catch (err) {
26 | alert("Unable to parse SVG");
27 | }
28 |
29 | return svgDoc;
30 | }
31 |
32 | // Get an SVG from the server and add a string version to textarea
33 | // Quite inefficient as we're going to convert it back to a XML object later.
34 | function getExampleSVG(filename) {
35 | $.get("examples/" + filename + ".svg", function(data) {
36 | $("#input-svg").val(xmlToString(data));
37 | });
38 | }
39 |
40 | // Convert string into filesize
41 | function getFileSize(str) {
42 | var size = str.length / 1000;
43 | if (size > 1000) {
44 | return (Math.round(size / 100) / 10) + " MB";
45 | } else {
46 | return (Math.round(size * 10) / 10) + " kB";
47 | }
48 | }
49 |
50 | // Clear element with given selector and add contents
51 | function addContentsToDiv(contents, selector) {
52 | var div = $(selector);
53 |
54 | if (div.length === 1) {
55 | div.empty();
56 | div.append(contents);
57 | }
58 | }
59 |
60 | function addSVGStats(selector, filesize, numElements) {
61 | var div = $(selector);
62 | if (div.length === 1) {
63 | div.empty();
64 | var ul = $('
');
65 | div.append(ul);
66 | ul.append($('
Filesize: ' + filesize + '
'));
67 | ul.append($('
Elements: ' + numElements + '
'));
68 | }
69 | }
70 |
71 | function optimiseSVG(svgObj) {
72 | // Create the new SVG string
73 | var svgStringNew = svgObj.toString();
74 |
75 | // Show new SVG image
76 | addContentsToDiv(svgStringNew, '#svg-after .svg-container');
77 |
78 | // Show SVG information
79 | var compression = Math.round(1000 * svgStringNew.length / svgObj.originalString.length) / 10;
80 | addSVGStats('#svg-after .svg-data', getFileSize(svgStringNew) + " (" + compression + "%)", svgObj.options.numElements);
81 |
82 | // Show code of updated SVG
83 | $('#output-container').text(svgStringNew);
84 | }
85 |
86 | function addOptions(svgObj) {
87 | var container = $('#options-container');
88 | container.empty();
89 |
90 | var selectOption = function() {
91 | svgObj.options[this.name] = !this.checked;
92 | optimiseSVG(svgObj);
93 | };
94 |
95 | for (var option in svgObj.options) {
96 | var checkbox = $('' + option +' ');
97 |
98 | checkbox.change(selectOption);
99 | container.append(checkbox);
100 | }
101 | }
102 |
103 | // Get SVG string from textarea with given id
104 | // Parse as XML and convert to jQuery object
105 | function loadSVG(id) {
106 | var svgStringOld = $('#input-svg').val();
107 | var svgDoc = stringToXML(svgStringOld);
108 |
109 | if (!svgDoc) { return; }
110 |
111 | var jQuerySVG = $(svgDoc).children();
112 | var svgObj = new SVG_Object(jQuerySVG[0]);
113 | svgObj.originalString = svgStringOld;
114 |
115 | // Output a nicely formatted file
116 | //svgObj.options.whitespace = 'pretty';
117 |
118 | // Remove ids
119 | svgObj.options.removeIDs = true;
120 |
121 | // Add original SVG image
122 | addContentsToDiv(svgStringOld, '#svg-before .svg-container');
123 | addSVGStats('#svg-before .svg-data', getFileSize(svgStringOld), jQuerySVG.find("*").length);
124 |
125 | // Add new SVG image
126 | optimiseSVG(svgObj);
127 |
128 | // Update interface
129 | $('#upload-container').hide("fast");
130 | addOptions(svgObj);
131 | $('#output-section').show();
132 | $('#optimise-section').show();
133 | }
134 |
135 | $(document).ready(function() {
136 | $('#upload-section > h2').click(function() {
137 | $('#upload-container').toggle('fast');
138 | });
139 |
140 | $('#output-section').hide();
141 | $('#optimise-section').hide();
142 | });
--------------------------------------------------------------------------------
/tests/comparison-tests.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Comparison tests for svg-optimiser
6 |
35 |
36 |
37 |
38 |
39 | Below are sets of test SVGs. Each set has a different transform applied to each of its elements.
40 | Press the optimise button to optimise each one and overlay the result on top in green.
41 |
42 |
43 |
TODO: show the % of file size reduction and how many transforms passed or failed.
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
131 |
132 |
133 |
--------------------------------------------------------------------------------
/scripts/SVG-elements.js:
--------------------------------------------------------------------------------
1 | /**********************************************************
2 | This file contains Objects uses to build a
3 | representation of an SVG DOM.
4 | ***********************************************************/
5 |
6 | // Generic SVG DOM element
7 | var SVG_Element = function(element) {
8 | this.tag = element.nodeName;
9 | this.attributes = {};
10 | this.originalAttributes = {};
11 | this.essentialAttributes = [];
12 | this.styles = {};
13 | this.children = [];
14 | this.text = "";
15 |
16 | // TODO: may need to replace this with actual namespace
17 | this.namespaceURI = 'http://www.w3.org/2000/svg';
18 |
19 | // Add attributes to two hashs, one for the original and one for optimising
20 | var i,
21 | attr,
22 | attrName,
23 | attributes = element.attributes || [];
24 |
25 | for (i = 0; i < attributes.length; i++){
26 | attr = attributes.item(i);
27 | attrName = attr.nodeName;
28 | this.originalAttributes[attrName] = attr.value;
29 | this.attributes[attrName] = attr.value;
30 | }
31 |
32 | // Convert position attributes to numbers and add default values
33 | var attributeData = shapeAttributes[this.tag];
34 | if (attributeData) {
35 | var digitAttributes = attributeData.parseAsDigit || [];
36 | for (i = 0; i < digitAttributes.length; i++) {
37 | attrName = digitAttributes[i];
38 | this.originalAttributes[attrName] = parseFloat(this.originalAttributes[attrName] || 0);
39 | }
40 | this.essentialAttributes = attributeData.essential || [];
41 | }
42 |
43 | // Parse transform
44 | if (this.attributes.transform) {
45 | this.addTransform(this.attributes.transform);
46 | }
47 |
48 | for (i = 0; i < element.childNodes.length; i++) {
49 | var child = element.childNodes[i];
50 | if (child instanceof Text) {
51 | // Tag contains text
52 | if (child.data.replace(/^\s*/, "") !== "") {
53 | this.text = child.data;
54 | }
55 | } else {
56 | this.children.push(this.getChild(child));
57 | }
58 | }
59 |
60 | };
61 |
62 | // Copy attributes so we can optimise them without losing them
63 | SVG_Element.prototype.getOriginalAttributes = function() {
64 | this.attributes = {};
65 | for (var attr in this.originalAttributes) {
66 | this.attributes[attr] = this.originalAttributes[attr];
67 | }
68 |
69 | // Should we remove this element when optimising
70 | this.toRemove = false;
71 | };
72 |
73 | // TODO: make sure this works with multiple transforms
74 | SVG_Element.prototype.addTransform = function(transform) {
75 | this.transform = SVG_optimise.parseTransforms(transform);
76 | // TODO: Only doing this so it shows up when we write the object
77 | // Need to fix so that we can do this without calling optimise
78 | this.attributes.transform = transform;
79 | };
80 |
81 | SVG_Element.prototype.write = function(options, depth) {
82 | if (this.toRemove) { return ""; }
83 |
84 | depth = depth || 0;
85 | var indent = (options.whitespace === 'remove') ? '' : new Array(depth + 1).join(' ');
86 |
87 | // Open tag
88 | var str = indent + '<' + this.tag;
89 |
90 | // Write attributes
91 | for (var attr in this.attributes) {
92 | str += ' ' + attr + '="' + this.attributes[attr] + '"';
93 | }
94 |
95 | if (!this.toSkip) { depth++; }
96 |
97 | // Add child information
98 | var childString = "";
99 | for (var i = 0; i < this.children.length; i++) {
100 | childString += this.children[i].write(options, depth);
101 | }
102 |
103 | if (this.toSkip) { return childString; }
104 |
105 | if (this.text.length + childString.length > 0) {
106 | str += ">" + options.newLine;
107 | if (this.text) { str += indent + " " + this.text; }
108 | str += childString + indent + "" + this.tag + ">";
109 | } else {
110 | str += "/>" + options.newLine;
111 | }
112 |
113 | return str;
114 | };
115 |
116 | // TODO: get this work with skipped elements
117 | SVG_Element.prototype.createSVGObject = function() {
118 | var element = document.createElementNS(this.namespaceURI, this.tag);
119 |
120 | for (var attr in this.attributes) {
121 | element.setAttribute(attr, this.attributes[attr]);
122 | }
123 |
124 | if (this.text) {
125 | var textNode = document.createTextNode(this.text);
126 | element.appendChild(textNode);
127 | }
128 |
129 | for (var i = 0; i < this.children.length; i++) {
130 | element.appendChild(this.children[i].createSVGObject());
131 | }
132 |
133 | return element;
134 | };
135 |
136 | SVG_Element.prototype.optimise = function(options) {
137 | // Get set copy of attributes to optimise
138 | this.getOriginalAttributes();
139 |
140 | this.attributeCounts = 0;
141 | for (var attr in this.attributes) {
142 | this.attributeCounts++;
143 | }
144 |
145 | this.elementSpecificOptimisations(options);
146 |
147 | // If an shape element lacks some dimension then don't draw it
148 | if (options.removeRedundantShapes && this.essentialAttributes) {
149 | for (var i = 0; i < this.essentialAttributes.length; i++) {
150 | if (!this.attributes[this.essentialAttributes[i]]) {
151 | this.toRemove = true;
152 | // If we remove the element, then remove its children
153 | return;
154 | }
155 | }
156 | }
157 |
158 | for (var i = 0; i < this.children.length; i++) {
159 | this.children[i].optimise(options);
160 | }
161 | };
162 |
163 | // Overwritten by other object classes
164 | SVG_Element.prototype.elementSpecificOptimisations = function(options) {
165 | if (this.transform) {
166 | this.attributes = this.applyTransformation(this.attributes, options);
167 | }
168 | };
169 |
170 | SVG_Element.prototype.applyTransformation = function(coordinates, options) {
171 | for (var i = 0; i < this.transform.length; i++) {
172 | var transform = this.transform[i];
173 | var transformFunction = this[transform[0]];
174 |
175 | // TODO: strip out meaningless transforms
176 | if (transformFunction) {
177 | coordinates = transformFunction.call(this, coordinates, transform.slice(1));
178 | // Remove transformation from the attribute hash
179 | // TOOD: Check there are no other transformations in the attribute
180 | delete coordinates.transform;
181 | } else {
182 | console.warn("No transform function " + transform + " for " + this.tag);
183 | }
184 | }
185 |
186 | return coordinates;
187 | };
188 |
189 | SVG_Element.prototype.translate = function(coordinates, parameters) {
190 | var attributes = SVG_optimise.transformShape.translate(this.tag, coordinates, parameters);
191 | // TODO: Move this to the translate function when we decide how to update attributes
192 | $.extend(coordinates, attributes);
193 | return coordinates;
194 | };
195 |
196 | // Path element
197 | // https://www.w3.org/TR/SVG/paths.html
198 | var SVG_Path_Element = function(element) {
199 | SVG_Element.call(this, element);
200 |
201 | // Convert path d attribute to array of arrays
202 | if (this.attributes.d) {
203 | this.path = SVG_optimise.parsePath(this.attributes.d);
204 | }
205 | };
206 | SVG_Path_Element.prototype = Object.create(SVG_Element.prototype);
207 |
208 | SVG_Path_Element.prototype.elementSpecificOptimisations = function(options) {
209 | // Replace current d attributed with optimised version
210 | if (this.path) {
211 | var optimisedPath = this.path;
212 |
213 | if (this.transform) {
214 | optimisedPath = this.applyTransformation(optimisedPath, options);
215 | }
216 |
217 | optimisedPath = SVG_optimise.optimisePath(optimisedPath, options);
218 | // TODO: don't replace attribute but write a new one instead
219 | this.attributes.d = SVG_optimise.getPathString(optimisedPath, options);
220 | }
221 |
222 | };
223 |
224 | SVG_Path_Element.prototype.translate = function(coordinates, parameters) {
225 | return SVG_optimise.transformPath.translate(coordinates, parameters);
226 | };
227 |
228 | SVG_Path_Element.prototype.scale = function(coordinates, parameters) {
229 | return SVG_optimise.transformPath.scale(coordinates, parameters);
230 | };
231 |
232 |
233 | var SVG_Polyline_Element = function(element) {
234 | SVG_Element.call(this, element);
235 |
236 | // Convert path d attribute to array of arrays
237 | if (this.attributes.points) {
238 | this.points = this.attributes.points.split(/\s*[,\s]+/).map(parseFloat);
239 | }
240 | };
241 | SVG_Polyline_Element.prototype = Object.create(SVG_Element.prototype);
242 |
243 |
244 | SVG_Polyline_Element.prototype.elementSpecificOptimisations = function(options) {
245 | if (this.transform) {
246 | this.attributes.points = this.points;
247 | this.attributes = this.applyTransformation(this.attributes, options);
248 |
249 | // TODO: Maybe move this to optimise-functions
250 | var coordinates = this.attributes.points;
251 |
252 | var pathString = "";
253 | for (var i = 0; i < coordinates.length; i++) {
254 | var n = coordinates[i];
255 | var d = options.positionDecimals(n);
256 | // Add a space if this is no the first digit and if the digit positive
257 | pathString += (i > 0 && (n > 0 || d == '0')) ? " " + d : d;
258 | }
259 | this.attributes.points = pathString;
260 | }
261 | };
262 |
263 | var SVG_Group_Element = function(element) {
264 | SVG_Element.call(this, element);
265 | };
266 | SVG_Group_Element.prototype = Object.create(SVG_Element.prototype);
267 |
268 | SVG_Group_Element.prototype.elementSpecificOptimisations = function(options) {
269 | if (options.removeCleanGroups && !this.attributeCounts) {
270 | this.toSkip = true;
271 | }
272 | };
273 |
274 |
275 | // Create the child element of an given element with the correct Objects
276 | SVG_Element.prototype.getChild = function(child) {
277 | switch (child.nodeName) {
278 | case 'path':
279 | return new SVG_Path_Element(child);
280 | case 'polyline':
281 | return new SVG_Polyline_Element(child);
282 | case 'g':
283 | return new SVG_Group_Element(child);
284 | default:
285 | return new SVG_Element(child);
286 | }
287 | };
288 |
289 |
290 | // Base object containing the SVG elements
291 | // Also where the optimisation options are stored
292 | var SVG_Root = function(svgString) {
293 | var jQuerySVG = svgString;
294 |
295 | // If passed a string, convert to JQuery object other assume we already have a JQuery object
296 | if (typeof svgString === 'string') {
297 | jQuerySVG = SVG_optimise.svgToJQueryObject(svgString);
298 | }
299 |
300 | this.elements = SVG_Element.prototype.getChild(jQuerySVG);
301 | this.options = {
302 | whitespace: 'remove',
303 | positionDecimals: SVG_optimise.getRoundingFunction('decimal places', 1),
304 | removeRedundantShapes: true,
305 | removeCleanGroups: true,
306 | };
307 | };
308 |
309 | SVG_Root.prototype.optimise = function() {
310 | return this.elements.optimise(this.options);
311 | };
312 |
313 | // Return a string representing an SVG
314 | SVG_Root.prototype.write = function() {
315 | this.options.newLine = (this.options.whitespace === 'remove') ? "": "\n";
316 | return this.elements.write(this.options);
317 | };
318 |
319 | // Return an SVG objec that can be inserted into the DOM
320 | SVG_Root.prototype.createSVGObject = function() {
321 | return this.elements.createSVGObject();
322 | };
323 |
324 | //var obj = new SVG_Root()
325 |
--------------------------------------------------------------------------------
/scripts/optimise-functions.js:
--------------------------------------------------------------------------------
1 | var shapeAttributes = {
2 | 'path': { essential: ['d'] },
3 | 'polygon': { essential: ['points'] },
4 | 'polyline': { essential: ['points'] },
5 | 'rect': { parseAsDigit: ['x', 'y', 'width', 'height'], essential: ['width', 'height'] },
6 | 'circle': { parseAsDigit: ['cx', 'cy', 'r'], essential: ['r'] },
7 | 'ellipse': { parseAsDigit: ['cx', 'cy', 'rx', 'ry'], essential: ['r', 'ry'] },
8 | 'line': { parseAsDigit: ['x1', 'y1', 'x2', 'y2'] },
9 | };
10 |
11 | var SVG_optimise = {
12 | svgToJQueryObject: function(svgString) {
13 | // Replace any leading whitespace which will mess up XML parsing
14 | svgString = svgString.replace(/^[\s\n]*/, "");
15 |
16 | // Parse SVG as XML
17 | // TODO: wrap in a try and fail gracefully with badly formatted XML
18 | var svgDoc = $.parseXML(svgString);
19 | return $(svgDoc).children()[0];
20 | },
21 |
22 | // Parse digit string to digit, keeping any final units
23 | // e.g. "str" -> 'str'
24 | // e.g. "3.4" -> { number: 3.4 }
25 | // e.g. "3.4 px" -> { number: 3.4, units: 'px' }
26 | parseNumber: function(str) {
27 | // TODO: Maybe move regex somewhere else
28 | var reDigit = /^\s*([-+]?[\d\.]+)(e[-+]?[\d\.]+)?\s*(%|em|ex|px|pt|pc|cm|mm|in)?\s*$/i;
29 | var digits = reDigit.exec(str);
30 |
31 | if (digits === null) { return str; }
32 |
33 | var num = { number: parseFloat(digits[1] + (digits[2] || "")) };
34 | if (digits[3]) { num.units = digits[3]; }
35 |
36 | return num;
37 | },
38 |
39 | /**
40 | * Parse the value for the "d" attribute of a path, returning an array of arrays.
41 | * The first value in each sub-array is a letter refering to the type of line segment.
42 | * The other values are the numerical parameters for that segment.
43 | * e.g "M10,20 L23,45" -> [['M', 10, 20], ['L', 23, 45]]
44 | * TODO: ensure commands have correct number of parameters
45 | */
46 | parsePath: function(dAttr) {
47 | var reCommands = /([ACHLMQSTVZ])([-\+\d\.\s,e]*)/gi;
48 | var reDigits = /([-+]?[\d\.]+)([eE][-+]?[\d\.]+)?/g;
49 | var commands, path = [];
50 |
51 | // Converts a string of digits to an array of floats
52 | var getDigits = function(digitString) {
53 | var digit, digits = [];
54 | if (digitString) {
55 | while (digit = reDigits.exec(digitString)) {
56 | digits.push(parseFloat(digit));
57 | }
58 | }
59 | return digits;
60 | };
61 |
62 | while (commands = reCommands.exec(dAttr)) {
63 | var letter = commands[1];
64 | var digits = getDigits(commands[2]);
65 | path.push([letter].concat(digits));
66 | }
67 |
68 | return path;
69 | },
70 |
71 | // Split a string from a style attribute into a object of styles
72 | // e.g. style="fill:#269276;opacity:1" => {fill: '#269276', opacity: '1'}
73 | parseStyle: function(styleString) {
74 | var styles = {};
75 | var styleArray = styleString.split(/\s*;\s*/);
76 | var reColonSplit = /\s*:\s*/;
77 |
78 | for (var i = 0; i < styleArray.length; i++) {
79 | var value = styleArray[i].split(reColonSplit);
80 |
81 | if (value.length === 2 && value[0] !== "" && value[1] !== "") {
82 | styles[value[0]] = this.parseNumber(value[1]);
83 | }
84 | }
85 |
86 | return styles;
87 | },
88 |
89 | // Convert transform attribute into an array of [transformation, digits]
90 | parseTransforms: function(transformString) {
91 | var reTransform = /(translate|scale|rotate|matrix|skewX|skewY)\s*\(([-\+\d\.\s,e]+)\)/gi;
92 | var transformName, digit, transform, transforms = [];
93 |
94 | while (transform = reTransform.exec(transformString)) {
95 | transformName = transform[1].toLowerCase();
96 | if (transformName === 'skewx') transformName = 'skewX';
97 | if (transformName === 'skewy') transformName = 'skewY';
98 |
99 | digits = transform[2].split(/\s*[,\s]+/);
100 | transform = [transformName];
101 |
102 | for (var i = 0; i < digits.length; i++) {
103 | digit = parseFloat(digits[i]);
104 | if (!isNaN(digit)) {
105 | transform.push(digit);
106 | }
107 | }
108 | transforms.push(transform);
109 | }
110 |
111 | return transforms;
112 | },
113 |
114 | // Return a function that given a number optimises it.
115 | // type === 'decimal places': round to a number of decimal places
116 | // type === 'significant figure': round to a number of significant figures (needs work)
117 | getRoundingFunction: function(type, level) {
118 | var roundingFunction;
119 | level = parseInt(level, 10);
120 |
121 | if (!isNaN(level)) {
122 | var scale = Math.pow(10, level);
123 |
124 | if (type === 'decimal places') {
125 | roundingFunction = function(n) { return Math.round(n * scale) / scale; };
126 | } else if (type === 'significant figures') {
127 | roundingFunction = function(n) {
128 | if (n === 0) { return 0; }
129 | var mag = Math.pow(10, level - Math.ceil(Math.log(n < 0 ? -n: n) / Math.LN10));
130 | return Math.round(n * mag) / mag;
131 | };
132 | } else {
133 | console.warn("No such rounding function, " + type);
134 | roundingFunction = function(n) { return n; };
135 | }
136 |
137 | return function(n) {
138 | if (isNaN(n)) {
139 | return n;
140 | } else {
141 | return roundingFunction(n);
142 | }
143 | };
144 |
145 | } else {
146 | // If level not properly defined, return identity function
147 | return function(str) { return str; };
148 | }
149 | },
150 |
151 | // Apply transform to elements, rect, circle, ellipse, line
152 | transformShape: {
153 | translate: function(tag, attributes, parameters) {
154 | var dx = parameters[0] || 0;
155 | var dy = parameters[1] || 0;
156 | var newAttributes = {};
157 |
158 | if (tag === 'rect') {
159 | newAttributes.x = (attributes.x || 0) + dx;
160 | newAttributes.y = (attributes.y || 0) + dy;
161 | } else if (tag === 'circle' || tag === 'ellipse') {
162 | newAttributes.cx = (attributes.cx || 0) + dx;
163 | newAttributes.cy = (attributes.cy || 0) + dy;
164 | } else if (tag === 'line') {
165 | newAttributes.x1 = (attributes.x1 || 0) + dx;
166 | newAttributes.x2 = (attributes.x2 || 0) + dx;
167 | newAttributes.y1 = (attributes.y1 || 0) + dy;
168 | newAttributes.y2 = (attributes.y2 || 0) + dy;
169 | } else if (tag === 'polyline' || tag === 'polygon') {
170 | var points = attributes.points || [];
171 | newAttributes.points = [];
172 | for (var i = 0; i < points.length; i += 2) {
173 | newAttributes.points[i] = (points[i] || 0) + dx;
174 | newAttributes.points[i + 1] = (points[i + 1] || 0) + dy;
175 | }
176 | } else {
177 | console.warn("Element " + tag + " could not be translated");
178 | }
179 |
180 | return newAttributes;
181 | },
182 | },
183 |
184 | transformPath: {
185 | translate: function(pathCommands, parameters) {
186 | var dx = parameters[0] || 0;
187 | var dy = parameters[1] || 0;
188 |
189 | // TODO: move these elsewhere
190 | var simpleTranslations = 'MLQTCS';
191 | var nullTranslations = 'mlhvqtcsZza';
192 |
193 | var translatedPath = [];
194 | var command, commandLetter, translatedCommand, i, j;
195 |
196 | for (i = 0; i < pathCommands.length; i++) {
197 | command = pathCommands[i];
198 | commandLetter = command[0];
199 | translatedCommand = command.slice();
200 |
201 | // For simple commands, just add (dx, dy) to each pair of values
202 | if (simpleTranslations.indexOf(commandLetter) > -1) {
203 | for (j = 1; j < command.length;) {
204 | translatedCommand[j++] += dx;
205 | translatedCommand[j++] += dy;
206 | }
207 | } else if (commandLetter === 'H') {
208 | // Should only ever be one command if we have optimised
209 | for (j = 1; j < command.length; j++) {
210 | translatedCommand[j] += dx;
211 | }
212 | } else if (commandLetter === 'V') {
213 | // Should only ever be one command if we have optimised
214 | for (j = 1; j < command.length; j++) {
215 | translatedCommand[j] += dy;
216 | }
217 | } else if (commandLetter === 'A') {
218 | for (j = 1; j < command.length; j += 7) {
219 | translatedCommand[j + 5] += dx;
220 | translatedCommand[j + 6] += dy;
221 | }
222 | } else if (i === 0 && commandLetter === 'm') {
223 | // Paths starting with a relative m should be treated as an absolute M
224 | translatedCommand[1] += dx;
225 | translatedCommand[2] += dy;
226 | } else if (nullTranslations.indexOf(commandLetter) === -1){
227 | console.warn("Unexpected letter in path: " + commandLetter);
228 | }
229 |
230 | translatedPath.push(translatedCommand);
231 | }
232 |
233 | return translatedPath;
234 | },
235 | scale: function(pathCommands, parameters) {
236 | var dx = parameters[0] || 0;
237 | var dy = parameters[1] || 0;
238 |
239 | // TODO: move these elsewhere
240 | var simpleScales = 'mlqtcsz';
241 |
242 | var scaledPath = [];
243 | var command, commandLetter, scaledCommand, i, j;
244 |
245 | for (i = 0; i < pathCommands.length; i++) {
246 | command = pathCommands[i];
247 | commandLetter = command[0].toLowerCase();
248 | scaledCommand = command.slice();
249 |
250 | // For simple commands, just add (dx, dy) to each pair of values
251 | if (simpleScales.indexOf(commandLetter) > -1) {
252 | for (j = 1; j < command.length;) {
253 | scaledCommand[j++] *= dx;
254 | scaledCommand[j++] *= dy;
255 | }
256 | } else if (commandLetter === 'h') {
257 | // Should only ever be one command if we have optimised
258 | for (j = 1; j < command.length; j++) {
259 | scaledCommand[j] *= dx;
260 | }
261 | } else if (commandLetter === 'v') {
262 | // Should only ever be one command if we have optimised
263 | for (j = 1; j < command.length; j++) {
264 | scaledCommand[j] *= dy;
265 | }
266 | } else if (commandLetter === 'a') {
267 | // TODO: Check this works
268 | for (j = 1; j < command.length; j += 7) {
269 | if (dx > 0) {
270 | scaledCommand[j] *= dx;
271 | } else {
272 | scaledCommand[j] *= -dx;
273 | // Flip sweep flag
274 | scaledCommand[j + 4] = 1 - scaledCommand[j + 4];
275 | }
276 | if (dy > 0) {
277 | scaledCommand[j + 1] *= dy;
278 | } else {
279 | // Flip sweep flag
280 | scaledCommand[j + 1] *= -dy;
281 | scaledCommand[j + 4] = 1 - scaledCommand[j + 4];
282 | }
283 | scaledCommand[j + 5] *= dx;
284 | scaledCommand[j + 6] *= dy;
285 | }
286 | } else {
287 | console.warn("Unexpected letter in path: " + command[0]);
288 | }
289 |
290 | scaledPath.push(scaledCommand);
291 | }
292 |
293 | return scaledPath;
294 | }
295 | },
296 |
297 | // Given an array of arrays of the type from by parsePath,
298 | // optimise the commands and return the reduced array
299 | // TODO: option to use relative or absolute paths
300 | optimisePath: function(path, options) {
301 | var pathLength = path.length;
302 |
303 | // If there's only one command (other than z), then there's no path
304 | if (pathLength < 2 || (pathLength === 2 && path[1] === 'z')) {
305 | return [];
306 | }
307 |
308 | // If a path only consists of m commands (and z) we remove it
309 | var onlyMoveCommands = true;
310 |
311 | var optimisedPath = [];
312 | var currentCommand = [];
313 |
314 | for (var i = 0; i < pathLength; i++) {
315 | var command = path[i];
316 | var commandType = command[0];
317 | var lowerCaseLetter = commandType.toLowerCase(); // For easier checking
318 |
319 | // Test whether paths contain only MmZz commands so we can remove them
320 | // Don't remove paths of the form Mx1 y1 x2 y2, since these draw a line
321 | if (onlyMoveCommands) {
322 | if (lowerCaseLetter !== 'z') {
323 | if (lowerCaseLetter !== 'm') {
324 | onlyMoveCommands = false;
325 | } else {
326 | onlyMoveCommands = command.length < 5;
327 | }
328 | }
329 | }
330 |
331 | if (commandType !== currentCommand[0]) {
332 | currentCommand = command.slice();
333 | optimisedPath.push(currentCommand);
334 | } else if (lowerCaseLetter === 'm') {
335 | // With multiple m commands, replace old comand with the new one.
336 | // TODO: Need to take into account if commands are relative
337 | currentCommand = command.slice();
338 | optimisedPath[optimisedPath.length - 1] = currentCommand;
339 | } else {
340 | // Combine path commands when they are the same (and not M or m)
341 | Array.prototype.push.apply(currentCommand, command.slice(1));
342 | }
343 |
344 | // Relative horizontal and vertical commands can be added together
345 | if ((commandType === 'h' || commandType === 'v')) {
346 | while (currentCommand.length > 2) {
347 | currentCommand[1] += currentCommand.pop();
348 | }
349 | }
350 | }
351 |
352 | // Paths that are only move command can be ignored
353 | if (onlyMoveCommands) { return []; }
354 |
355 | return optimisedPath;
356 | },
357 |
358 | // Given an array of arrays of the type from by parsePath,
359 | // return a string for that path's `d` attribute
360 | getPathString: function(path, options) {
361 | var pathString = "";
362 |
363 | for (var i = 0; i < path.length; i++) {
364 | var command = path[i];
365 | if (command.length) {
366 | pathString += command[0];
367 |
368 | for (var j = 1; j < command.length; j++) {
369 | var n = command[j];
370 | var d = options.positionDecimals(n);
371 | // Add a space if this is no the first digit and if the digit positive
372 | pathString += (j > 1 && (n > 0 || d == '0')) ? " " + d : d;
373 | }
374 | }
375 | }
376 |
377 | return pathString;
378 | },
379 |
380 | };
381 |
--------------------------------------------------------------------------------
/tests/qunit-tests.js:
--------------------------------------------------------------------------------
1 |
2 | // optimiser-function.js unit tests
3 |
4 | QUnit.test("parseNumber", function(assert) {
5 | assert.deepEqual(SVG_optimise.parseNumber(""), "", "Empty string");
6 | assert.deepEqual(SVG_optimise.parseNumber("0"), { number: 0 }, "Zero");
7 | assert.deepEqual(SVG_optimise.parseNumber("0.000"), { number: 0 }, "Zero point Zero");
8 | assert.deepEqual(SVG_optimise.parseNumber("12"), { number: 12 }, "Integer");
9 | assert.deepEqual(SVG_optimise.parseNumber("1.0230"), { number: 1.023 }, "Decimal");
10 | assert.deepEqual(SVG_optimise.parseNumber("-1.0230"), { number: -1.023 }, "Negative");
11 | assert.deepEqual(SVG_optimise.parseNumber("+1.0230"), { number: 1.023 }, "Leading plus");
12 | assert.deepEqual(SVG_optimise.parseNumber("1.023e4"), { number: 10230 }, "Exponent");
13 | assert.deepEqual(SVG_optimise.parseNumber("1.023e+4"), { number: 10230 }, "Exponent with plus");
14 | assert.deepEqual(SVG_optimise.parseNumber("-10.2E-2"), { number: -0.102 }, "Negative exponent");
15 | assert.deepEqual(SVG_optimise.parseNumber("-1.2%"), { number: -1.2, units: '%' }, "Percent");
16 | assert.deepEqual(SVG_optimise.parseNumber("-1.23e3 px"), { number: -1230, units: 'px' }, "Pixels");
17 | assert.deepEqual(SVG_optimise.parseNumber("-1.23e3EM"), { number: -1230, units: 'EM' }, "Em");
18 | assert.deepEqual(SVG_optimise.parseNumber("1-2"), '1-2', "Subtract");
19 | assert.deepEqual(SVG_optimise.parseNumber("1e"), '1e', "Leading number");
20 | assert.deepEqual(SVG_optimise.parseNumber("word1"), 'word1', "Trailing number");
21 | assert.deepEqual(SVG_optimise.parseNumber("#2692e6"), '#2692e6', "Hex colour");
22 | });
23 |
24 | QUnit.test("parsePath", function(assert) {
25 | assert.deepEqual(SVG_optimise.parsePath(""), [], "Empty string");
26 | assert.deepEqual(SVG_optimise.parsePath("M 10 23 L 45 789"), [['M', 10, 23], ['L', 45, 789]], "Integers and spaces");
27 | assert.deepEqual(SVG_optimise.parsePath("M 10 23 a150,254 0 0 0 15,150"), [['M', 10, 23], ['a', 150, 254, 0, 0, 0, 15, 150]], "Multiple values");
28 | assert.deepEqual(SVG_optimise.parsePath("M 10 23 a 150 254 0 0 0 15,150z"), [['M', 10, 23], ['a', 150, 254, 0, 0, 0, 15, 150], ['z']], "Final z");
29 | assert.deepEqual(SVG_optimise.parsePath("m10,23L45,789"), [['m', 10, 23], ['L', 45, 789]], "Integers and no spaces");
30 | assert.deepEqual(SVG_optimise.parsePath(" M 10,23 l45 , 789 "), [['M', 10, 23], ['l', 45, 789]], "Integers and mixed spaces");
31 | assert.deepEqual(SVG_optimise.parsePath("M 10.0 23.2 L 45.0001 0.00"), [['M', 10, 23.2], ['L', 45.0001, 0]], "Decimals");
32 | assert.deepEqual(SVG_optimise.parsePath("M 10.0-23.2 L-45.1-0.00, -4 3"), [['M', 10, -23.2], ['L', -45.1, 0, -4, 3]], "Negatives");
33 | assert.deepEqual(SVG_optimise.parsePath("M 12.3 2.3e2 L-1e1 -7e-3"), [['M', 12.3, 230], ['L', -10, -0.007]], "Exponents");
34 | });
35 |
36 | QUnit.test("parseStyle", function(assert) {
37 | assert.deepEqual(SVG_optimise.parseStyle(""), {}, "Empty string");
38 | assert.deepEqual(SVG_optimise.parseStyle("fill"), {}, "String");
39 | assert.deepEqual(SVG_optimise.parseStyle("fill:"), {}, "Missing value");
40 | assert.deepEqual(SVG_optimise.parseStyle(":red"), {}, "Missing key");
41 | assert.deepEqual(SVG_optimise.parseStyle("fill:#269276"), { fill: '#269276' }, "Non-number");
42 | assert.deepEqual(SVG_optimise.parseStyle("fill:#269276;opacity:1"), { fill: '#269276', opacity: { number: 1 } }, "Two values");
43 | assert.deepEqual(SVG_optimise.parseStyle("fill: #269276; opacity :1;"), { fill: '#269276', opacity: { number: 1 } }, "Trailing semi-colon");
44 | assert.deepEqual(SVG_optimise.parseStyle("font-size: 12px ; opacity:0.9%"), { 'font-size': { number: 12, units: 'px' }, opacity: { number: 0.9, units: '%' }}, "With units");
45 | });
46 |
47 | QUnit.test("parseTransforms", function(assert) {
48 | assert.deepEqual(SVG_optimise.parseTransforms(""), [], "Empty string");
49 | assert.deepEqual(SVG_optimise.parseTransforms("translate(five)"), [], "No number");
50 | assert.deepEqual(SVG_optimise.parseTransforms("move(1)"), [], "Not a valid transform");
51 | assert.deepEqual(SVG_optimise.parseTransforms("SkEwX(2)"), [['skewX', 2]], "Not a valid transform");
52 | assert.deepEqual(SVG_optimise.parseTransforms("translate(3)"), [['translate', 3]], "Integer");
53 | assert.deepEqual(SVG_optimise.parseTransforms(" rotate ( 4 ) "), [['rotate', 4]], "Extra spaces");
54 | assert.deepEqual(SVG_optimise.parseTransforms("rotate(5 -8.5)"), [['rotate', 5, -8.5]], "Negative decimal separated by space");
55 | assert.deepEqual(SVG_optimise.parseTransforms("rotate(5,-8.5)"), [['rotate', 5, -8.5]], "Negative decimal separated by comma");
56 | assert.deepEqual(SVG_optimise.parseTransforms("matrix(0, 1.0 , -2.07 4E2 5e-3,-7.3e+2 )"), [['matrix', 0, 1, -2.07, 400, 0.005, -730]], "Matrix with mixed numbers and delimiters");
57 | assert.deepEqual(SVG_optimise.parseTransforms("SCALE(-0.005, 3.220) Translate(0),rotate( -9.2, 3e-2 7) "), [['scale', -0.005, 3.22], ['translate', 0], ['rotate', -9.2, 0.03, 7]], "Multiple transforms");
58 | });
59 |
60 | QUnit.test("getRoundingFunction", function(assert) {
61 | var roundingFunction;
62 |
63 | roundingFunction = SVG_optimise.getRoundingFunction('decimal places', 0);
64 | assert.equal(roundingFunction("5"), "5", "Zero decimal places: Not a number");
65 | assert.equal(roundingFunction(5), 5, "Zero decimal places: Integer");
66 | assert.equal(roundingFunction(5.0), 5, "Zero decimal places: Decimal with zero");
67 | assert.equal(roundingFunction(5.09), 5, "Zero decimal places: Decimal");
68 | assert.equal(roundingFunction(5.4), 5, "Zero decimal places: Round down");
69 | assert.equal(roundingFunction(5.6), 6, "Zero decimal places: Round up");
70 | assert.equal(roundingFunction(-5.4), -5, "Zero decimal places: Negative round down");
71 | assert.equal(roundingFunction(-5.6), -6, "Zero decimal places: Positive round up");
72 |
73 | roundingFunction = SVG_optimise.getRoundingFunction('decimal places', 2);
74 | assert.equal(roundingFunction("5"), "5", "Zero decimal places: Not a number");
75 | assert.equal(roundingFunction(5), 5, "Zero decimal places: Integer");
76 | assert.equal(roundingFunction(5.0), 5, "Zero decimal places: Decimal with zero");
77 | assert.equal(roundingFunction(5.09), 5.09, "Zero decimal places: Decimal");
78 | assert.equal(roundingFunction(5.004), 5, "Zero decimal places: Round down");
79 | assert.equal(roundingFunction(5.006), 5.01, "Zero decimal places: Round up");
80 | assert.equal(roundingFunction(-5.004), -5, "Zero decimal places: Negative round down");
81 | assert.equal(roundingFunction(-5.006), -5.01, "Zero decimal places: Positive round up");
82 | assert.equal(roundingFunction(5.095), 5.1, "Zero decimal places: Round twice");
83 | assert.equal(roundingFunction(5.995), 6, "Zero decimal places: Round three times");
84 | });
85 |
86 | QUnit.test("optimisePath", function(assert) {
87 | var options = {};
88 |
89 | assert.deepEqual(SVG_optimise.optimisePath([], options), [], "Empty array");
90 | assert.deepEqual(SVG_optimise.optimisePath([[]], options), [], "Array with empty array");
91 | assert.deepEqual(SVG_optimise.optimisePath([['M', 5, 10], ['M', 12, -5.5], ['z']], options), [], "Remove empty path");
92 | assert.deepEqual(SVG_optimise.optimisePath([['M', 5, 10], ['L', 12, 21]], options), [['M', 5, 10], ['L', 12, 21]], "Two commands");
93 | assert.deepEqual(SVG_optimise.optimisePath([['M', 5, 10], ['L', 12, 21], ['z']], options), [['M', 5, 10], ['L', 12, 21], ['z']], "Include z command");
94 | assert.deepEqual(SVG_optimise.optimisePath([['M', 5, 10], ['L', 12, 21], ['L', 18, 12]], options), [['M', 5, 10], ['L', 12, 21, 18, 12]], "Combine repeated commands");
95 | assert.deepEqual(SVG_optimise.optimisePath([['M', 5, 10], ['v', 12, -5.5]], options), [['M', 5, 10], ['v', 6.5]], "Combine vertical command");
96 | assert.deepEqual(SVG_optimise.optimisePath([['M', 5, 10], ['h', 12, -5.5], ['h', 13]], options), [['M', 5, 10], ['h', 19.5]], "Combine repeated horizontal command");
97 | });
98 |
99 | QUnit.test("getPathString", function(assert) {
100 | var options = {
101 | positionDecimals: function(n) { return n; }
102 | };
103 |
104 | assert.equal(SVG_optimise.getPathString([], options), "", "Empty array");
105 | assert.equal(SVG_optimise.getPathString([[]], options), "", "Array with empty array");
106 | assert.equal(SVG_optimise.getPathString([['M', 5, 10], ['L', 12, 21]], options), "M5 10L12 21", "Two commands");
107 | assert.equal(SVG_optimise.getPathString([['M', 5, 10], ['a', 150, 254, 0, 0, 0, 15, 150]], options), "M5 10a150 254 0 0 0 15 150", "Arc command");
108 | assert.equal(SVG_optimise.getPathString([['M', 5, 10], ['L', 12, 21], ['z']], options), "M5 10L12 21z", "Include z command");
109 | assert.equal(SVG_optimise.getPathString([['M', -5, 10], ['L', 12, -21]], options), "M-5 10L12-21", "Commands with negatives");
110 | });
111 |
112 | // Transformation tests
113 | QUnit.test("transformShape.translate", function(assert) {
114 | var transformFunction = SVG_optimise.transformShape.translate;
115 | assert.deepEqual(transformFunction('rect', { x: 10, y: 20, width: 25, height: 16 }, []), { x: 10, y: 20 }, 'Null translate rect');
116 | assert.deepEqual(transformFunction('rect', { x: 10, y: 20, width: 25, height: 16 }, [12, 7]), { x: 22, y: 27 }, 'Translate rect in 2D');
117 | assert.deepEqual(transformFunction('rect', { x: 10, y: 20, width: 25, height: 16 }, [-2.2, 0.5]), { x: 7.8, y: 20.5 }, 'Translate rect in 2D with negative and decimal');
118 | assert.deepEqual(transformFunction('rect', { }, [12, 7]), { x: 12, y: 7 }, 'Translate rect with missing attributes');
119 | assert.deepEqual(transformFunction('circle', { cx: 10, cy: 20, r: 10 }, [-2.2, 0.5]), { cx: 7.8, cy: 20.5 }, 'Translate circle in 2D');
120 | assert.deepEqual(transformFunction('circle', { x: 10, cy: 20, r: 10 }, [-2.2, 0.5]), { cx: -2.2, cy: 20.5 }, 'Translate circle with cx replaced by x');
121 | assert.deepEqual(transformFunction('ellipse', { cx: 10, cy: 20, rx: 10, ry: 12 }, [-2.2, 0.5]), { cx: 7.8, cy: 20.5 }, 'Translate ellipse in 2D');
122 | assert.deepEqual(transformFunction('line', { x1: 10, y1: 20, x2: 110.5, y2: -120 }, [-2.2, 0.5]), { x1: 7.8, y1: 20.5, x2: 108.3, y2: -119.5 }, 'Translate line in 2D');
123 | assert.deepEqual(transformFunction('polyline', { points: [30, 70, 40, 80, 60, 80, 70, 70] }, [-2.2, 0.5]), { points: [27.8, 70.5, 37.8, 80.5, 57.8, 80.5, 67.8, 70.5] }, 'Translate polyline in 2D');
124 | assert.deepEqual(transformFunction('polygon', { points: [30, 70, 40, 80, 60, 80, 70, 70] }, [-2.2, 0.5]), { points: [27.8, 70.5, 37.8, 80.5, 57.8, 80.5, 67.8, 70.5] }, 'Translate polyline in 2D');
125 | });
126 |
127 | QUnit.test("transformPath.translate", function(assert) {
128 | var transformFunction = SVG_optimise.transformPath.translate;
129 | assert.deepEqual(transformFunction(
130 | [['M', 10, 20], ['L', 32.1, -4.3]], []),
131 | [['M', 10, 20], ['L', 32.1, -4.3]], "Null translate");
132 | assert.deepEqual(transformFunction(
133 | [['M', 10, 20], ['L', 32.1, -4.3]], [0, 0]),
134 | [['M', 10, 20], ['L', 32.1, -4.3]], "Translate by (0, 0)");
135 | assert.deepEqual(transformFunction(
136 | [['M', 10, 20], ['L', 32.1, -4.3]], [5.5]),
137 | [['M', 15.5, 20], ['L', 37.6, -4.3]], "Single value");
138 | assert.deepEqual(transformFunction(
139 | [['M', 10, 20], ['L', 32.1, -4.3]], [5, 8]),
140 | [['M', 15, 28], ['L', 37.1, 3.7]], "Absolute ML path");
141 | assert.deepEqual(transformFunction(
142 | [['m', 10, 20], ['l', 32.1, -4.3]], [5, 8]),
143 | [['m', 15, 28], ['l', 32.1, -4.3]], "Start with relative m");
144 | assert.deepEqual(transformFunction(
145 | [['m', 10, 20, 32.1, -4.3]], [5, 8]),
146 | [['m', 15, 28, 32.1, -4.3]], "Path using all relative ms");
147 | assert.deepEqual(transformFunction(
148 | [['M', 10, 20], ['H', 32.1], ['V', -40.0], ['z']], [5.4, -8]),
149 | [['M', 15.4, 12], ['H', 37.5], ['V', -48], ['z']], "VHz path");
150 | assert.deepEqual(transformFunction(
151 | [['M', 10, 20], ['L', 32.1, -4.3], ['l', 32.1, -40.0], ['z']], [5.4, -8]),
152 | [['M', 15.4, 12], ['L', 37.5, -12.3], ['l', 32.1, -40.0], ['z']], "Mixed absolute and relative paths");
153 | // TODO: add multipath
154 | });
155 |
156 | // Whole element tests
157 | var readWriteTests = {
158 | "Don't remove empty element": '',
159 | "Don't optimise attributes": '',
160 | multipath: '',
161 | 'Test indentation': '',
162 | 'Non-path elements': ''
163 | };
164 |
165 | // Tests with default options
166 | QUnit.test("Read then write SVG string", function(assert) {
167 | var obj, str, test;
168 | for (test in readWriteTests) {
169 | str = readWriteTests[test];
170 | obj = new SVG_Root(str);
171 | assert.equal(obj.write(), str, test);
172 | }
173 | });
174 |
175 | // Read an SVG string, create a DOM element from it, then read that and write it as a string
176 | QUnit.test("Test createSVGObject", function(assert) {
177 | for (var test in readWriteTests) {
178 | var str = readWriteTests[test];
179 | var obj1 = new SVG_Root(str);
180 | var obj2 = obj1.createSVGObject();
181 | var obj3 = new SVG_Root(obj2);
182 | assert.equal(obj3.write(), str, test);
183 | }
184 | });
185 |
186 | QUnit.test("Test optimisations options", function(assert) {
187 | var tests = {
188 | 'Pretty indendation': {
189 | input: '',
190 | options: { whitespace: 'pretty' },
191 | output: ''
192 | },
193 | 'Remove empty elements': {
194 | input: '',
195 | options: { removeRedundantShapes: true },
196 | output: ''
197 | },/*
198 | 'Include empty elements': {
199 | input: '',
200 | options: { removeRedundantShapes: false },
201 | output: ''
202 | },*/
203 | 'Remove empty group': {
204 | input: '',
205 | options: { removeCleanGroups: true },
206 | output: ''
207 | },
208 | };
209 |
210 | for (var test in tests) {
211 | var data = tests[test];
212 | obj = new SVG_Root(data.input);
213 | $.extend(obj.options, data.options);
214 | obj.optimise();
215 | assert.equal(obj.write(), data.output, test);
216 | }
217 |
218 | });
219 |
220 | QUnit.test("Translate shapes", function(assert) {
221 | var tests = [
222 | ['Rect translate 1D', '', ''],
223 | ['Rect translate 2D', '', ''],
224 | ['Polyline translate 2D', '', ''],
225 | ['Circle translate 2D', '', '']
226 | ];
227 |
228 | for (var i = 0; i < tests.length; i++) {
229 | var obj = new SVG_Root(tests[i][1]);
230 | obj.optimise();
231 | assert.equal(obj.write(), tests[i][2], tests[i][0]);
232 | }
233 | });
234 |
235 | QUnit.test("Translate paths", function(assert) {
236 | var paths = {
237 | absolute: "M10 40A42 24 0 1 1 90 40 C80,50 70,30 60,40 S50,50, 40,40 20,50, 10,40 M86 50Q74 40 62 50 T38 50 14 50 L30 90H45V80 L55,80 55,90 70,90z",
238 | relative: "m10 40a42 24 0 1 1 80 0c-10 10 -20 -10 -30 0s-10 10 -20 0 -20 10 -30 0m76 10q-12 -10 -24 0t-24 0 -24 0l16 40h15v-10l10 0 0 10 15 0z",
239 | 'just m commands': "m5 12 10 -10 10 10 z"
240 | };
241 |
242 | var translations = {
243 | 'translate(0)': {
244 | transform: "translate(0)",
245 | absolute: "M10 40A42 24 0 1 1 90 40C80 50 70 30 60 40S50 50 40 40 20 50 10 40M86 50Q74 40 62 50T38 50 14 50L30 90H45V80L55 80 55 90 70 90z",
246 | relative: "m10 40a42 24 0 1 1 80 0c-10 10-20-10-30 0s-10 10-20 0-20 10-30 0m76 10q-12-10-24 0t-24 0-24 0l16 40h15v-10l10 0 0 10 15 0z",
247 | 'just m commands': "m5 12 10-10 10 10z"
248 | },
249 | 'translate(0, 0)': {
250 | transform: "translate(0, 0)",
251 | absolute: "M10 40A42 24 0 1 1 90 40C80 50 70 30 60 40S50 50 40 40 20 50 10 40M86 50Q74 40 62 50T38 50 14 50L30 90H45V80L55 80 55 90 70 90z",
252 | relative: "m10 40a42 24 0 1 1 80 0c-10 10-20-10-30 0s-10 10-20 0-20 10-30 0m76 10q-12-10-24 0t-24 0-24 0l16 40h15v-10l10 0 0 10 15 0z",
253 | 'just m commands': "m5 12 10-10 10 10z"
254 | },
255 | 'translate(4.5)': {
256 | transform: "translate(4.5)",
257 | absolute: "M14.5 40A42 24 0 1 1 94.5 40C84.5 50 74.5 30 64.5 40S54.5 50 44.5 40 24.5 50 14.5 40M90.5 50Q78.5 40 66.5 50T42.5 50 18.5 50L34.5 90H49.5V80L59.5 80 59.5 90 74.5 90z",
258 | relative: "m14.5 40a42 24 0 1 1 80 0c-10 10-20-10-30 0s-10 10-20 0-20 10-30 0m76 10q-12-10-24 0t-24 0-24 0l16 40h15v-10l10 0 0 10 15 0z",
259 | 'just m commands': "m9.5 12 10-10 10 10z"
260 | },
261 | 'translate(4.5, -0.7)': {
262 | transform: "translate(4.5, -0.7)",
263 | absolute: "M14.5 39.3A42 24 0 1 1 94.5 39.3C84.5 49.3 74.5 29.3 64.5 39.3S54.5 49.3 44.5 39.3 24.5 49.3 14.5 39.3M90.5 49.3Q78.5 39.3 66.5 49.3T42.5 49.3 18.5 49.3L34.5 89.3H49.5V79.3L59.5 79.3 59.5 89.3 74.5 89.3z",
264 | relative: "m14.5 39.3a42 24 0 1 1 80 0c-10 10-20-10-30 0s-10 10-20 0-20 10-30 0m76 10q-12-10-24 0t-24 0-24 0l16 40h15v-10l10 0 0 10 15 0z",
265 | 'just m commands': "m9.5 11.3 10-10 10 10z"
266 | },
267 | };
268 |
269 | for (var translate in translations) {
270 | var data = translations[translate];
271 |
272 | for (var pathType in paths) {
273 | var testPath = '';
274 | var expectedPath = '';
275 | var obj = new SVG_Root(testPath);
276 | obj.optimise();
277 | assert.equal(obj.write(), expectedPath, translate + ' transform ' + pathType);
278 | }
279 | }
280 | });
281 |
282 | QUnit.test("Optimise path elements", function(assert) {
283 | var tests = [
284 | // Paths to remove
285 | ['Remove empty path', '', ''],
286 | ['Remove path with one M command', '', ''],
287 | ['Remove path with Mz command', '', ''],
288 | ['Remove path with just M commands', '', ''],
289 |
290 | // Paths to remove exception
291 | ["Don't remove paths with M commands and multiple parameters", '', ''],
292 |
293 | // Work with paths that use every command
294 | ["Handle multipath",
295 | '',
296 | ''],
297 |
298 | // Repeated commands
299 | ["Ignore M command followed by a second M command", '', ''],
300 | ['Remove repeated command', '', ''],
301 | ];
302 |
303 | for (var i = 0; i < tests.length; i++) {
304 | var obj = new SVG_Root(tests[i][1]);
305 | obj.optimise();
306 | assert.equal(obj.write(), tests[i][2], tests[i][0]);
307 | }
308 | });
309 |
--------------------------------------------------------------------------------
/scripts/optimiseSVG.js:
--------------------------------------------------------------------------------
1 | // Node in an SVG document
2 | // Contains all the options for optimising how the SVG is written
3 | var SVG_Element = function(element, parents) {
4 | this.tag = element.nodeName;
5 | this.attributes = {};
6 | this.styles = {};
7 | this.parents = parents;
8 | this.children = [];
9 | this.text = "";
10 |
11 | // Add attributes to hash
12 | // Style attributes have a separate hash
13 | if (element.attributes) {
14 | for (var i = 0; i < element.attributes.length; i++){
15 | var attr = element.attributes.item(i);
16 | var attrName = attr.nodeName;
17 |
18 | if (attrName === 'style') {
19 | $.extend(this.styles, this.parseStyle(attr.value));
20 | } else if (defaultStyles[attrName] !== undefined || nonEssentialStyles[attrName] !== undefined) {
21 | // Style written as a separate attribute
22 | this.styles[attrName] = this.parseNumber(attr.value);
23 | } else {
24 | this.attributes[attrName] = attr.value;
25 | }
26 | }
27 | }
28 |
29 | // Add children
30 | for (var i = 0; i < element.childNodes.length; i++) {
31 | var child = element.childNodes[i];
32 | if (child instanceof Text) {
33 | // Tag contains text
34 | if (child.data.replace(/^\s*/, "") !== "") {
35 | this.text = child.data;
36 | }
37 | } else {
38 | this.children.push(new SVG_Element(child, this));
39 | }
40 | }
41 | };
42 |
43 | // Parse digit string to digit, keeping any final units
44 | SVG_Element.prototype.parseNumber = function(str) {
45 | // TODO: Maybe move regex somewhere else
46 | var reDigit = /^\s*([-+]?[\d\.]+)([eE][-+]?[\d\.]+)?\s*(%|em|ex|px|pt|pc|cm|mm|in)\s*$/;
47 | var digit = reDigit.exec(str);
48 | var n = parseFloat(digit ? digit[1] + (digit[2] || "") : str);
49 |
50 | if (isNaN(n)) {
51 | return [str];
52 | } else {
53 | return [n, digit ? digit[3] : ""];
54 | }
55 | };
56 |
57 | // Split a string from a path "d" attribute into a list of letters and values
58 | SVG_Element.prototype.parsePath = function(dAttr) {
59 | var reCommands = /([ACHLMQSTVZ])([-\+\d\.\s,e]*)/gi;
60 | var reDigits = /([-+]?[\d\.]+)([eE][-+]?[\d\.]+)?/g;
61 | var letters = [];
62 | var values = [];
63 |
64 | // Converts a string of digits to an array of floats
65 | var getDigits = function(digitString) {
66 | var digit, digits = [];
67 |
68 | if (digitString) {
69 | while (digit = reDigits.exec(digitString)) {
70 | digits.push(parseFloat(digit));
71 | }
72 | }
73 | return digits;
74 | };
75 |
76 | while (commands = reCommands.exec(dAttr)) {
77 | letters.push(commands[1]);
78 | values.push(getDigits(commands[2]));
79 | }
80 |
81 | return { letters: letters, values: values };
82 | };
83 |
84 | // Split a string from a style attribute into a hash of styles
85 | // e.g. style="fill:#269276;opacity:1" => {fill: '#269276', opacity: '1'}
86 | SVG_Element.prototype.parseStyle = function(styleString) {
87 | var styles = {};
88 | var styleArray = styleString.split(/\s*;\s*/);
89 |
90 | for (var i = 0; i < styleArray.length; i++) {
91 | var value = styleArray[i].split(/\s*:\s*/);
92 |
93 | if (value.length === 2) {
94 | styles[value[0]] = this.parseNumber(value[1]);
95 | }
96 | }
97 |
98 | return styles;
99 | };
100 |
101 | // Convert transform attribute into an array of [transformation, digits]
102 | SVG_Element.prototype.parseTransforms = function() {
103 | var reTransform = /([a-z]+)\s*\(([-\+\d\.\s,e]+)\)/gi;
104 | var transform;
105 | this.transforms = [];
106 |
107 | if (this.attributes.transform) {
108 | while (transform = reTransform.exec(this.attributes.transform)) {
109 | digits = transform[2].split(/\s*[,\s]+\s*/);
110 | this.transforms.push({
111 | type: transform[1],
112 | digits: $.map(digits, parseFloat)
113 | });
114 | }
115 | }
116 |
117 | for (var i = 0; i < this.children.length; i++) {
118 | this.children[i].parseTransforms();
119 | }
120 | };
121 |
122 | // Return an object mapping attribute: value
123 | // Only return used attributes and optimised values
124 | SVG_Element.prototype.getUsedAttributes = function(options) {
125 | var transformedAttributes = {};
126 |
127 | // Parse position values as numbers
128 | for (var attr in this.attributes) {
129 | if (positionAttributes.indexOf(attr) !== -1) {
130 | transformedAttributes[attr] = parseFloat(this.attributes[attr]);
131 | } else if (attr === 'd') {
132 | this.pathCommands = this.parsePath(this.attributes[attr], options);
133 | }
134 | }
135 |
136 | //console.log(transformedAttributes);
137 |
138 | // If one attribute is a transformation then try to apply transformations in reverse order
139 | // If successful, remove the transformation
140 | while (this.transforms.length > 0) {
141 | var transformation = this.transforms.pop();
142 |
143 | // If this is a group, test whether we can apply this transform to its child elements
144 | // If so, remove the transform from the group and apply to each child
145 | // If there is only one child element move the transform anyway because
146 | // we might be able to get then be able to get rid of the group element
147 | if (this.tag === 'g') {
148 | var applyTransform = true;
149 | if (this.children.length > 1) {
150 | for (var i = 0; i < this.children.length; i++) {
151 | if (!this.children[i].canTransform(transformation)) {
152 | applyTransform = false;
153 | break;
154 | }
155 | }
156 | }
157 |
158 | if (applyTransform) {
159 | // Add transformation to the front of children transforms
160 | // TODO: check adding to the front is correct
161 | for (var i = 0; i < this.children.length; i++) {
162 | this.children[i].transforms.unshift(transformation);
163 | }
164 | } else {
165 | // Failed to apply transform, so add it back
166 | this.transforms.push(transformation);
167 | break;
168 | }
169 | } else {
170 | // Not a group so try to apply to this element
171 | var newAttributes = this.applyTransformation(transformation, transformedAttributes);
172 | if (newAttributes) {
173 | transformedAttributes = newAttributes;
174 | } else {
175 | // Failed to apply transform, so add it back
176 | this.transforms.push(transformation);
177 | break;
178 | }
179 | }
180 | }
181 |
182 | transformedAttributes.transform = "";
183 | for (var i = 0; i < this.transforms.length; i++) {
184 | // Convert remaining transformations back into a string
185 | // TODO: truncate decimals and remove if identity transformation
186 | var transform = this.transforms[i];
187 | transformedAttributes.transform += transform.type + "(" + transform.digits.join(" ") + ")";
188 | }
189 |
190 | //console.log(this.getPathString(options))
191 |
192 | var usedAttributes = {};
193 | for (var attr in this.attributes) {
194 | // Remove attributes whose namespace has been removed and links to namespace URIs
195 | if (attr.indexOf(':') !== -1) {
196 | var ns = attr.split(':');
197 | if (!options.namespaces[ns[0]] || (ns[0] === 'xmlns' && !options.namespaces[ns[1]])) {
198 | continue;
199 | }
200 | }
201 |
202 | var value = transformedAttributes[attr] === undefined ? this.attributes[attr] : transformedAttributes[attr];
203 |
204 | // Attributes shouldn't be empty and this removes applied transformations
205 |
206 | if (value === "") { continue; }
207 |
208 | // Remove position attributes equal to 0 (the default value)
209 | if (options.removeDefaultAttributes &&
210 | positionAttributes.indexOf(attr) !== -1 &&
211 | options.positionDecimals(value) == 0) {
212 | continue;
213 | }
214 |
215 | // TODO: only remove ids that are not referenced elsewhere
216 | if (options.removeIDs && attr === 'id') {
217 | continue;
218 | }
219 |
220 | // Process values
221 |
222 | // TODO: convert tags to lowercase so will work with 'viewbox'
223 | // TODO: also apply decimal places to transforms
224 | if (attr === 'viewBox' || attr === 'points') {
225 | var values = value.split(/[\s,]+/);
226 | value = $.map(values, options.positionDecimals).join(" ");
227 | } else if (this.tag === 'svg' && (attr === 'width' || attr === 'height')) {
228 | value = options.svgSizeDecimals(value);
229 | } else if (this.tag === 'path' && attr === 'd') {
230 | value = this.getPathString(options);
231 | } else if (positionAttributes.indexOf(attr) !== -1 ) {
232 | value = options.positionDecimals(value);
233 | }
234 |
235 | usedAttributes[attr] = value;
236 | }
237 |
238 | return usedAttributes;
239 | };
240 |
241 | // Return a list of strings in the form "style:value" for the styles that are to be used
242 | // They are sorted alphabetically so the strings can be compared for sets of the same style
243 | SVG_Element.prototype.getUsedStyles = function(options) {
244 | if (!this.styles) { return []; }
245 |
246 | var usedStyles = [];
247 |
248 | // Ignore other fill or stroke attributes if value is none or it is transparent
249 | var ignoreFill = (this.styles['fill'] === 'none' || options.styleDecimals(this.styles['fill-opacity']) == 0);
250 | var ignoreStroke = (this.styles['stroke'] === 'none' || options.styleDecimals(this.styles['stroke-opacity']) == 0 || options.styleDecimals(this.styles['stroke-width']) == 0);
251 |
252 | if ((ignoreFill && ignoreStroke) || this.styles['visibility'] === 'hidden'|| options.styleDecimals(this.styles['opacity']) == 0) {
253 | // TODO: don't show this element
254 | // Seems this would only be likely for animations or some weird styling with groups
255 | }
256 |
257 | for (var style in this.styles) {
258 | var value = this.styles[style];
259 |
260 | if (value.length > 1) {
261 | // If we're multiplying positons by powers of 10, certain styles also need multiplying
262 | // TODO: will also have to change font sizes
263 | if (options.attributeNumTruncate[1] === 'order of magnitude' && style === 'stroke-width') {
264 | value = options.positionDecimals(value[0]) + value[1];
265 | } else {
266 | value = options.styleDecimals(value[0]) + value[1];
267 | }
268 | } else {
269 | value = value[0];
270 | }
271 |
272 | // Simplify colours, e.g. #ffffff -> #fff
273 | var repeated = value.match(/^#([0-9a-f])\1([0-9a-f])\2([0-9a-f])\3$/i);
274 | if (repeated) {
275 | value = '#' + repeated[1] + repeated[2] + repeated[3];
276 | }
277 |
278 | if (ignoreFill && style.substr(0, 4) === 'fill') { continue; }
279 | if (ignoreStroke && style.substr(0, 6) === 'stroke') { continue; }
280 | if (options.removeDefaultStyles && value == defaultStyles[style]) { continue; }
281 | if (options.removeNonEssentialStyles && options.nonEssentialStyles[style]) { continue; }
282 |
283 | usedStyles.push(style + ":" + value);
284 | }
285 |
286 | if (ignoreFill) { usedStyles.push('fill:none'); }
287 |
288 | return usedStyles.sort();
289 | };
290 |
291 | // Get a style string for this element and add to the passed in map
292 | // of stylesOfElements
293 | SVG_Element.prototype.createCSS = function(options, stylesOfElements) {
294 | var styles = this.getUsedStyles(options);
295 |
296 | if (styles.length > 0) {
297 | var styleString = styles.join(";");
298 |
299 | if (stylesOfElements[styleString]) {
300 | stylesOfElements[styleString].push(this);
301 | } else {
302 | stylesOfElements[styleString] = [this];
303 | }
304 | }
305 |
306 | for (var i = 0; i < this.children.length; i++) {
307 | this.children[i].createCSS(options, stylesOfElements);
308 | }
309 | };
310 |
311 | // Return a string representing the SVG element
312 | // All the optimisation is done here, so none of the original information is lost
313 | SVG_Element.prototype.toString = function(options, depth) {
314 | // Remove namespace information
315 | if (this.tag.indexOf(':') !== -1) {
316 | var ns = this.tag.split(':')[0];
317 | if (!options.namespaces[ns]) {
318 | return "";
319 | }
320 | }
321 |
322 | depth = depth || 0;
323 | var indent = (options.whitespace === 'remove') ? '' : new Array(depth + 1).join(' ');
324 | var str = indent + '<' + this.tag;
325 |
326 | var usedAttributes = this.getUsedAttributes(options);
327 |
328 | // If shape element lacks some dimension then don't draw it
329 | if (options.removeRedundantShapes && essentialAttributes[this.tag]) {
330 | var attributes = essentialAttributes[this.tag];
331 | for (var i = 0; i < attributes.length; i++) {
332 | if (!usedAttributes[attributes[i]]) {
333 | return "";
334 | }
335 | }
336 | }
337 |
338 | // Write attributes and count how many have been used
339 | var numUsedAttributes = 0;
340 | for (var attr in usedAttributes) {
341 | str += ' ' + attr + '="' + usedAttributes[attr] + '"';
342 | numUsedAttributes++;
343 | }
344 |
345 | // Write styles
346 | var usedStyles = this.getUsedStyles(options);
347 | if (options.styles === 'CSS' || options.styles === 'optimal' && this.class) {
348 | str += ' class="' + this.class + '"';
349 | } else if (usedStyles.length > 1) {
350 | // Write as all styles in a style attribute
351 | str += ' style="' + usedStyles.join(';') + '"';
352 | } else if (usedStyles.length === 1) {
353 | // Only one style, so just write that attribute
354 | var style = usedStyles[0].split(':');
355 | str += ' ' + style[0] + '="' + style[1] + '"';
356 | }
357 |
358 | // Don't write group if it has no attributes, but do write its children
359 | // Assume g element has no text (which it shouldn't)
360 | // TODO: if g contains styles could add styles to children (only if using CSS or there is 1 child)
361 |
362 | var childString = "";
363 | if (this.tag === 'g' && options.removeCleanGroups && !numUsedAttributes && !usedStyles.length) {
364 | for (var i = 0; i < this.children.length; i++) {
365 | childString += this.children[i].toString(options, depth + 1);
366 | }
367 | return childString;
368 | }
369 |
370 | // Get child information
371 | for (var i = 0; i < this.children.length; i++) {
372 | childString += this.children[i].toString(options, depth + 1);
373 | }
374 |
375 | if (this.text.length + childString.length > 0) {
376 | str += ">" + options.newLine;
377 | if (this.text) {
378 | str += indent + " " + this.text + options.newLine;
379 | }
380 | str += childString + indent + "" + this.tag + ">" + options.newLine;
381 | } else {
382 | // Don't write an empty element or a group with no children
383 | if ((options.removeEmptyElements && numUsedAttributes === 0) || this.tag === 'g') {
384 | return "";
385 | }
386 | str += "/>" + options.newLine;
387 | }
388 |
389 | options.numElements++;
390 | return str;
391 | };
392 |
393 | // Create a string for the 'd' attribute of a path
394 | SVG_Element.prototype.getPathString = function(options) {
395 | var coordString = "";
396 |
397 | if (this.pathCommands) {
398 | var letters = this.pathCommands.letters;
399 | var values = this.pathCommands.values;
400 |
401 | if (letters.length < 2 || (letters.length === 2 && letters[1] === 'z')) {
402 | return "";
403 | }
404 |
405 | var currentLetter;
406 | for (var i = 0; i < letters.length; i++) {
407 | coordString += (letters[i] === currentLetter) ? " " : letters[i];
408 | currentLetter = letters[i];
409 |
410 | if (values[i]) {
411 | for (var j = 0; j < values[i].length; j++) {
412 | var n = values[i][j];
413 | var d = options.positionDecimals(n);
414 | coordString += (j > 0 && (n > 0 || d == '0')) ? " " + d : d;
415 | }
416 | }
417 | }
418 | }
419 |
420 | return coordString;
421 | };
422 |
423 | // Return true if this element can be transformed
424 | // Update this as more transformations are implemented
425 | SVG_Element.prototype.canTransform = function(transformation) {
426 | if (this.tag !== 'g') {
427 | var implementedTransformations = {
428 | 'translate': ['rect', 'circle', 'ellipse', 'path'],
429 | 'scale': ['rect', 'circle', 'ellipse', 'path']
430 | };
431 |
432 | var transform = implementedTransformations[transformation.type];
433 |
434 | // Return false if we can't transform this element
435 | if (!transform || transform.indexOf(this.tag) === -1) { return false; }
436 | }
437 |
438 | // Check whether children can be transformed
439 | for (var i = 0; i < this.children.length; i++) {
440 | if (!this.children[0].canTransform(transformation)) {
441 | return false;
442 | }
443 | }
444 |
445 | return true;
446 | };
447 |
448 | SVG_Element.prototype.applyTransformation = function(transformation, attributes) {
449 | // TODO: Improve how this is done. Maybe have separate transformation functions
450 | if (this.tag === 'path' && this.pathCommands) {
451 | return this.transformPath(transformation, attributes);
452 | }
453 |
454 | var x, y, width, height;
455 | if (this.tag === 'rect') {
456 | x = 'x';
457 | y = 'y';
458 | width = 'width';
459 | height = 'height';
460 | } else if (this.tag === 'ellipse') {
461 | x = 'cx';
462 | y = 'cy';
463 | width = 'rx';
464 | height = 'ry';
465 | }
466 |
467 | if (x) {
468 | attributes[x] = attributes[x] || 0;
469 | attributes[y] = attributes[y] || 0;
470 | attributes[width] = attributes[width] || 0;
471 | attributes[height] = attributes[height] || 0;
472 |
473 | if (transformation.type === 'translate') {
474 | attributes[x] += transformation.digits[0] || 0;
475 | attributes[y] += transformation.digits[1] || 0;
476 | return attributes;
477 | }
478 |
479 | if (transformation.type === 'scale') {
480 | var scaleX = transformation.digits[0];
481 | var scaleY = transformation.digits.length === 2 ? transformation.digits[1] : transformation.digits[0];
482 | attributes[x] *= scaleX;
483 | attributes[y] *= scaleY;
484 | attributes[width] *= scaleX;
485 | attributes[height] *= scaleY;
486 | return attributes;
487 | }
488 | }
489 | return false;
490 | };
491 |
492 | // TODO: move transformations to a separate file
493 | SVG_Element.prototype.transformPath = function(transformation, attributes) {
494 | var letters = this.pathCommands.letters;
495 | var values = this.pathCommands.values;
496 |
497 | // TODO: move these elsewhere
498 | var simpleTranslations = 'MLQTCS';
499 | var nullTranslations = 'mlhvqtcsZz';
500 | var implementedScales = 'MmLlqQtTcCsS';
501 |
502 | var dx = transformation.digits[0] || 0;
503 | var dy = transformation.digits[1] || 0;
504 |
505 | if (transformation.type === 'translate') {
506 | for (var i = 0; i < letters.length; i++) {
507 | var letter = letters[i];
508 | var value = values[i];
509 |
510 | if (simpleTranslations.indexOf(letter) > -1) {
511 | for (var j = 0; j < value.length; j += 2) {
512 | value[j] += dx;
513 | value[j + 1] += dy;
514 | }
515 | } else if (letter === 'H') {
516 | for (var j = 0; j < value.length; j++) {
517 | value[j] += dx;
518 | }
519 | } else if (letter === 'V') {
520 | for (var j = 0; j < value.length; j++) {
521 | value[j] += dy;
522 | }
523 | } else if (letter === 'A') {
524 | for (var j = 0; j < values.length; j += 7) {
525 | values[j + 5] += dx;
526 | values[j + 6] += dy;
527 | }
528 | } else if (nullTranslations.indexOf(letter) === -1) {
529 | return false;
530 | }
531 | }
532 | } else if (transformation.type === 'scale') {
533 | for (var i = 0; i < letters.length; i++) {
534 |
535 | var letter = letters[i];
536 | var value = values[i];
537 |
538 | if (implementedScales.indexOf(letter) > -1) {
539 | for (var j = 0; j < value.length; j += 2) {
540 | value[j] *= dx;
541 | value[j + 1] *= dy;
542 | }
543 | } else if (letter === 'H') {
544 | for (var j = 0; j < value.length; j++) {
545 | value[j] *= dx;
546 | }
547 | } else if (letter === 'V') {
548 | for (var j = 0; j < value.length; j++) {
549 | value[j] *= dy;
550 | }
551 | } else if (letter === 'A' || letter === 'a') {
552 | // TODO: check this works
553 | for (var j = 0; j < value.length; j += 7) {
554 | if (dx > 0) {
555 | value[j] *= dx;
556 | } else {
557 | value[j] *= -dx;
558 | value[j + 4] = 1 - value[j + 4];
559 | }
560 | if (dy > 0) {
561 | value[j + 1] *= dy;
562 | } else {
563 | value[j + 1] *= -dy;
564 | value[j + 4] = 1 - value[j + 4];
565 | }
566 | value[j + 5] *= dx;
567 | value[j + 6] *= dy;
568 | }
569 | } else {
570 | return false;
571 | }
572 | }
573 | }
574 |
575 | // Success
576 | this.pathCommands.values = values;
577 | return attributes;
578 | };
579 |
580 | // Style element contains CSS data
581 | var SVG_Style_Element = function() {
582 | this.data = '';
583 |
584 | this.toString = function (options) {
585 | if ((options.styles === 'CSS' || options.styles === 'optimal') && this.data) {
586 | options.numElements++;
587 | return '';
588 | } else {
589 | return '';
590 | }
591 | };
592 |
593 | // Empty functions to avoid problems
594 | this.createCSS = function() {};
595 | this.parseTransforms = function() {};
596 | this.canTransform = function() {};
597 | };
598 |
599 | /************************************************************************
600 | A wrapper for SVG_Elements which store the options for optimisation
601 | Build from a jQuery object representing the SVG
602 | options:
603 | whitespace: 'remove', 'pretty'
604 | styles: 'optimal', 'CSS', 'styleString'
605 | *************************************************************************/
606 |
607 | var SVG_Object = function(jQuerySVG) {
608 | this.elements = new SVG_Element(jQuerySVG, null);
609 |
610 | // Add an empty style element
611 | // TODO: check one doesn't already exist
612 | this.elements.children.unshift(new SVG_Style_Element());
613 |
614 | // Set default options
615 | this.options = {
616 | whitespace: 'remove',
617 | styles: 'optimal',
618 | removeIDs: false,
619 | removeDefaultAttributes: true,
620 | removeDefaultStyles: true,
621 | removeNonEssentialStyles: true,
622 | removeEmptyElements: true,
623 | removeRedundantShapes: true,
624 | removeCleanGroups: true,
625 | applyTransforms: true,
626 | attributeNumTruncate: [1, 'decimal place'],
627 | styleNumTruncate: [1, 'significant figure'],
628 | svgSizeTruncate: [0, 'decimal place'],
629 | };
630 |
631 | this.options.nonEssentialStyles = nonEssentialStyles;
632 | this.options.namespaces = this.findNamespaces();
633 |
634 | //this.options.whitespace = 'pretty';
635 | };
636 |
637 | // Namespaces are attributes of the SVG element, prefaced with 'xmlns:'
638 | // Create a hash mapping namespaces to false, except for the SVG namespace
639 | SVG_Object.prototype.findNamespaces = function() {
640 | var namespaces = {};
641 |
642 | for (var attr in this.elements.attributes) {
643 | if (attr.slice(0,6) === 'xmlns:') {
644 | var ns = attr.split(':')[1];
645 | namespaces[ns] = (ns === 'svg');
646 | }
647 | }
648 |
649 | return namespaces;
650 | };
651 |
652 | SVG_Object.prototype.toString = function() {
653 | this.options.numElements = 0; // Not really an option, but handy to put here
654 |
655 | this.options.newLine = (this.options.whitespace === 'remove') ? "": "\n";
656 | this.options.positionDecimals = this.getDecimalOptimiserFunction(this.options.attributeNumTruncate);
657 | this.options.styleDecimals = this.getDecimalOptimiserFunction(this.options.styleNumTruncate);
658 | this.options.svgSizeDecimals = this.getDecimalOptimiserFunction(this.options.svgSizeTruncate);
659 |
660 | if (this.options.styles === 'CSS' || this.options.styles === 'optimal') {
661 | this.createCSS();
662 | }
663 |
664 | // Convert transform attributes to array of transforms
665 | this.elements.parseTransforms();
666 |
667 | return this.elements.toString(this.options);
668 | };
669 |
670 | // Return a function that given a number optimises it.
671 | // type === 'decimal place': round to a number of decimal places
672 | // type === 'significant figure': round to a number of significant figures (needs work)
673 | // type === 'order of magnitude': multiply by a power of 10, then round
674 | SVG_Object.prototype.getDecimalOptimiserFunction = function(parameters) {
675 | var level = parameters[0];
676 | var type = parameters[1];
677 |
678 | if (!isNaN(parseInt(level))) {
679 | var scale = Math.pow(10, level);
680 | var reDigit = /^\s*([-+]?[\d\.]+)([eE][-+]?[\d\.]+)?\s*(%|em|ex|px|pt|pc|cm|mm|in)\s*$/;
681 |
682 | var roundFunction;
683 | if (type === 'decimal place') {
684 | roundFunction = function(n) { return Math.round(n * scale) / scale; };
685 | } else if (type === 'significant figure') {
686 | roundFunction = function(n) {
687 | if (n == 0) { return 0; }
688 | var mag = Math.pow(10, level - Math.ceil(Math.log(n < 0 ? -n: n) / Math.LN10));
689 | return Math.round(n * mag) / mag;
690 | };
691 | } else if (type === 'order of magnitude') {
692 | roundFunction = function(n) { return Math.round(n * scale); };
693 | } else {
694 | roundFunction = function(n) { return n; };
695 | }
696 |
697 | return function(str) {
698 | // Parse digit string to digit, while keeping any final units
699 | var digit = reDigit.exec(str);
700 | var n = parseFloat(digit ? digit[1] + (digit[2] || "") : str);
701 |
702 | if (isNaN(n)) {
703 | return str;
704 | } else {
705 | return roundFunction(n) + (digit ? digit[3] : "");
706 | }
707 | };
708 | } else {
709 | // This shouldn't happen, but just in case, return an identity function
710 | return function(str) { return str; };
711 | }
712 | };
713 |
714 | // Convert styles to CSS
715 | // TODO: make sure this works with existing classes
716 | // TODO: make this work if a style element already exist (?)
717 | SVG_Object.prototype.createCSS = function() {
718 | // Map style strings to elements with those styles
719 | this.stylesOfElements = {};
720 | this.elements.createCSS(this.options, this.stylesOfElements);
721 |
722 | // These are used to define class names
723 | var letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
724 | var counter = 0;
725 |
726 | var getClassName = function(n) {
727 | var name = '';
728 | var len = letters.length;
729 | while (n >= 0) {
730 | name += letters.charAt(n % len);
731 | n -= len;
732 | }
733 | return name;
734 | };
735 |
736 | var styleString = '';
737 |
738 | for (var styles in this.stylesOfElements) {
739 | var elements = this.stylesOfElements[styles];
740 | if (this.options.styles === 'optimal' && elements.length === 1) { continue; }
741 |
742 | var styleName = getClassName(counter);
743 | styleString += '.' + styleName + '{' + this.options.newLine;
744 |
745 | // TODO: style this more nicely when using whitespace
746 | var styleList = styles.split(';');
747 | for (var i = 0; i < styleList.length; i++) {
748 | styleString += styleList[i] + ';' + this.options.newLine;
749 | }
750 |
751 | styleString += '}' + this.options.newLine;
752 |
753 | // TODO: Fix what to do here if a class already exists (unlikely)
754 | for (var i = 0; i < elements.length; i++) {
755 | elements[i].class = styleName;
756 | }
757 |
758 | counter++;
759 | }
760 |
761 | // Set style element's data
762 | this.elements.children[0].data = styleString;
763 | };
--------------------------------------------------------------------------------