svg-cleaner.js | |
---|---|
SVG CleanerA tool for cleaning SVG Files - (yet partial) port of Scour to JavaScript. Visit the original Scour - an SVG scrubber, http://codedread.com/scour/ Scour was created by Jeff Schiller. Please note that this is a partial port, which means it is not finsihed at all. For thoose who want to clean their SVG files and have them as clean as possible as I highly recommend to use the original Scour.py. (Please see the list of implemented and missing processing steps below.) LicenseSVG Cleaner Copyright 2012 Michael Schieben Scour Copyright 2010 Jeff Schiller Copyright 2010 Louis Simard Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. About the portI needed a library to work with SVG-Stacker, that could rename IDs and keep the references inside the SVG document structure intact. SVG-Stacker merges different svg files and needs to make sure that the ids from different files are unique in the merged version. If found that Scour implemented that feature. The goal of the port was to bring the power of Scour to the JavaScript world, make it available as commandline tool and usable as module for node.js. I tried to keep the ideas and way how Scour cleans SVG files. I translated the processing steps and copied most of the original comments from scour into the new source code, as they describe the original ideas best. I marked all of these orginial comments by putting 'Scour:' in the first line an used the markdown syntax for quotes (>).
| |
Implemented
| |
Missing
| |
Original Notes from Scour | |
Scour:
| |
| |
| |
| |
Implementation | var _ = require('underscore')
, fs = require('fs')
, cheerio = require('cheerio')
, CSSOM = require('cssom')
, $
; |
Namespace Prefixes that should be removed | var namespacePrefixes = ['dc', 'rdf', 'sodipodi', 'cc', 'inkscape']; |
Sanitize References | |
Scour: Removes the unreferenced ID attributes. | function removeUnreferencedIDs() {
var identifiedElements = $('[id]');
var referencedIDs = findReferencedElements();
_(identifiedElements).each(function(node) {
var $node = $(node);
var id = $node.attr('id');
if(!_(referencedIDs).has(id)) {
$node.removeAttr('id');
};
});
} |
Style-Properties and Element-Attributes that might contain an id, a reference to another object | var referencingProperties = ['fill', 'stroke', 'filter', 'clip-path', 'mask', 'marker-start', 'marker-end', 'marker-mid'];
var REFERENCE_TYPE = {
STYLE_TAG: 0,
XLINK: 1,
STYLE_ATTRIBUTE: 2,
ATTRIBUTE: 4
};
function addReferencingElement(ids, id, referenceType, node, additionalInfo) {
var id = id.replace(/#/g, '');
if(!_(ids).has(id)) {
ids[id] = [];
}
if(_(additionalInfo).isUndefined()) {
additionalInfo = [];
}
ids[id].push([referenceType, node, additionalInfo]);
return ids;
} |
extracts #id out of CSS url('#id') | function extractReferencedId(value) {
var v = value.replace(/[\s]/g, '');
if(v.indexOf('url') !== 0) {
return false;
}
return value.replace(/[\s]/g, '').slice(4, -1).replace(/["']/g, '');
} |
replaced #fromid in CSS url('#fromid') to url('#toid') | function replaceReferencedId(value, idFrom, idTo) {
var re = new RegExp('url\\([\'"]?#' + idFrom + '[\'"]?\\)', 'g');
return value.replace(re, "url(#" + idTo + ")");
} |
Scour:
| function findReferencedElements() {
var ids = {};
_($('*')).each(function(node) {
var $node = $(node);
if(node.name == 'style') {
var styles = CSSOM.parse($node.text());
_(styles.cssRules).each(function(rule) {
_(referencingProperties).each(function(referencingProperty) {
if(_(rule.style).has(referencingProperty)) {
var id = extractReferencedId(rule.style[referencingProperty]);
if(id) {
addReferencingElement(ids, id, REFERENCE_TYPE.STYLE_TAG, node);
}
}
});
});
return;
} |
if xlink:href is set, then grab the id | var href = $node.attr('xlink:href');
if(href) {
addReferencingElement(ids, href, REFERENCE_TYPE.XLINK, node);
} |
now get all style properties | var styles = parseStyles($node.attr('style'));
_(referencingProperties).each(function(referencingProperty) { |
first check attributes | var value = $node.attr(referencingProperty);
if(!_.isUndefined(value)) {
var id = extractReferencedId(value);
if(id) {
addReferencingElement(ids, id, REFERENCE_TYPE.ATTRIBUTE, node, referencingProperty);
}
} |
then inline styles | if(_(styles).has(referencingProperty)) {
var id = extractReferencedId(styles[referencingProperty]);
if(id) {
addReferencingElement(ids, id, REFERENCE_TYPE.STYLE_ATTRIBUTE, node);
}
}
});
});
return ids;
} |
scour:
| function shortenIDs(startNumber) {
if(_.isUndefined(startNumber)) {
startNumber = 1;
}
var $identifiedElements = $('[id]');
var referencedIDs = findReferencedElements(); |
scour:
| var idList = _(_(referencedIDs).keys()).filter(function(id) {
return ($identifiedElements.find('#' + id).size() > 0);
});
idList = _(idList).sortBy(function(id) {
return referencedIDs[id].length;
}).reverse();
_(idList).each(function(id) {
var shortendID = intToID(startNumber++); |
scour:
| if(id == shortendID) {
return;
} |
scour:
| while($identifiedElements.find('#' + shortendID).size() > 0) {
shortendID = intToID(startNumber++);
} |
scour:
| renameID(id, shortendID, $identifiedElements, referencedIDs);
});
} |
scour:
| function intToID(num) {
var idName = '';
while (num > 0) {
num--;
idName = String.fromCharCode((num % 26) + 'a'.charCodeAt(0)) + idName;
num = Math.floor(num / 26)
}
return idName;
} |
scour:
| function renameID(idFrom, idTo, $identifiedElements, referencedIDs) {
var $definingNode = $identifiedElements.find('#' + idFrom);
$definingNode.attr('id', idTo);
var referringNodes = referencedIDs[idFrom];
_(referringNodes).each(function(referenceTypeNodeAndAdditionalInfos) {
var property = referenceTypeNodeAndAdditionalInfos[0];
var node = $(referenceTypeNodeAndAdditionalInfos[1]);
switch(property) {
case REFERENCE_TYPE.STYLE_TAG:
node.text(replaceReferencedId(node.text(), idFrom, idTo));
break;
case REFERENCE_TYPE.XLINK:
node.attr('xlink:href', '#' + idTo);
break;
case REFERENCE_TYPE.STYLE_ATTRIBUTE:
node.attr('style', replaceReferencedId(node.attr('style'), idFrom, idTo));
break;
case REFERENCE_TYPE.ATTRIBUTE:
var attributeName = referenceTypeNodeAndAdditionalInfos[2];
node.attr(attributeName, replaceReferencedId(node.attr(attributeName), idFrom, idTo));
break;
default: |
unkonw reference_type | }
});
} |
scour:
| function removeUnreferencedElements() {
var referencedIDs = findReferencedElements();
var alwaysKeepTags = ['font', 'style', 'metadata', 'script', 'title', 'desc'];
function removeUnreferencedElementsInTag(node) {
_($(node).children()).each(function(node) {
|
scour:
| if(_(alwaysKeepTags).indexOf(node.name) !== -1) {
return;
}
var $node = $(node);
var id = $node.attr('id');
if(!_(referencedIDs).has(id)) {
if(node.name == 'g') { |
scour:
| removeUnreferencedElementsInTag(node);
} else {
$(node).remove();
}
}
});
} |
scour:
| _($('defs')).each(removeUnreferencedElementsInTag); |
scour:
| var identifiedElements = $('* > linearGradient[id], * > radialGradient[id], * > pattern[id]');
_(identifiedElements).each(function(node) {
var id = $(node).attr('id');
if(!_(referencedIDs).has(id)) {
$(node).remove();
}
});
} |
| |
Namspace | function removeNamespacedElements(namespacesToRemove) {
var namespacedElements = _($('*')).filter(function(node) {
var $node = $(node)[0];
if($node.type !== 'tag') {
return false;
}
var namespaceTagName = $node.name.split(':');
if(namespaceTagName.length < 2) {
return false;
}
if(_.indexOf(namespacesToRemove, namespaceTagName[0]) === -1) {
return false;
}
return true;
});
$(namespacedElements).remove();
}
function removeNamespacedAttributes(namespacesToRemove) {
$('*').each(function(ix, node) {
_.each(node.attribs, function(value, key) {
var namespaceAtrributeName = key.split(':');
if(namespaceAtrributeName.length < 2) {
return;
}
if(_.indexOf(namespacesToRemove, namespaceAtrributeName[0]) !== -1) {
$(node).removeAttr(key);
}
});
});
} |
| |
Comments | function removeComments() {
var commentNodes = []; |
process on NODE level | function searchForComment(node) {
if(node.type == 'comment') {
commentNodes.push(node);
}
if(!_(node.children).isUndefined()) {
_.each(node.children, searchForComment);
}
}
searchForComment($._root);
_.each(commentNodes, function(node) {
$(node).remove();
});
} |
| |
Repair Style | function mayContainTextNode(node) {
var result = true;
var elementsThatDontContainText = ['rect', 'circle', 'ellipse', 'line', 'polygon', 'polyline', 'path', 'image', 'stop'];
var elementsThatCouldContainText = ['g', 'clipPath', 'marker', 'mask', 'pattern', 'linearGradient', 'radialGradient', 'symbol'];
if(node.type == 'comment') {
result = false;
}
else if(_(elementsThatDontContainText).include(node.name)) {
result = false;
}
else if(_(elementsThatCouldContainText).include(node.name)) {
result = false;
if(!_(node.children).isUndefined() && node.children.length) {
result = _(node.children).any(mayContainTextNode);
}
}
return result;
} |
returns a float without the unit | function styleValue(string) {
return parseFloat(string.replace(/[^-\d\.]/g, ''));
} |
removes properties, by a given list of keys | function removeStyleProperties(styles, properties) {
_(styles).each(function(value, key) {
if(_(properties).include(key)) {
delete styles[key]
}
});
return styles;
} |
a simple CSS parser, returns object with {property: value} | function parseStyles(inlineStyles) {
var styles = {};
if(_.isUndefined(inlineStyles)) {
return styles;
}
var styleRules = inlineStyles.split(';');
_(styleRules).each(function(style) {
var keyValue = style.split(':');
if(keyValue.length == 2) {
styles[keyValue[0].replace(/[\s]/g, '')] = keyValue[1];
}
});
return styles;
}
function renderStyles(styles) { |
if(!_(styles).size()) {
return '';
}
var inlineStyles = '';
_.each(styles, function(property, value) {
inlineStyles += value + ':' + property + ';';
});
return inlineStyles;
}
function repairStyles() {
$('*').each(function(ix, node) {
repairStyle($(node));
});
}
function repairStyle(node) {
var $node = node;
var styles = parseStyles($node.attr('style'));
if(!_(styles).size()) {
return;
} | |
scour:
| _.each(['fill', 'stroke'], function(property) {
if(!_(styles).has(property)) {
return;
}
var chunk = styles[property].split(') ');
if (chunk.length == 2 && chunk[0].substr(0, 5) == 'url(#' || chunk[0].substr(0, 6) == 'url("#' || chunk[0].substr(0, 6) == "url('#" && chunk[1] == 'rgb(0, 0, 0)') {
styles[property] = chunk[0] + ')'
}
});
var STYLE_PROPERTIES = [];
STYLE_PROPERTIES['stroke'] = ['stroke', 'stroke-width', 'stroke-linejoin', 'stroke-opacity', 'stroke-miterlimit', 'stroke-linecap', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity'];
STYLE_PROPERTIES['fill'] = ['fill', 'fill-opacity', 'fill-rule'];
STYLE_PROPERTIES['text'] = [ 'font-family', 'font-size', 'font-stretch', 'font-size-adjust', 'font-style', 'font-variant', 'font-weight', 'letter-spacing', 'line-height', 'kerning', 'text-align', 'text-anchor', 'text-decoration', 'text-rendering', 'unicode-bidi', 'word-spacing', 'writing-mode'];
var VENDOR_PREFIXES = ['-inkscape'];
var SVG_ATTRIBUTES = ['clip-rule', 'display', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'font-family', 'font-size', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'line-height', 'marker', 'marker-end', 'marker-mid', 'marker-start', 'opacity', 'overflow', 'stop-color', 'stop-opacity', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'visibility'];
if(_(styles).has('opacity')) {
var opacity = parseFloat(styles['opacity']);
if(opacity != 0.0) {
return;
} |
if opacity='0' then all fill and stroke properties are useless, remove them | styles = removeStyleProperties(styles, _.union(STYLE_PROPERTIES['stroke'], STYLE_PROPERTIES['fill']));
}
|
if stroke:none, then remove all stroke-related properties (stroke-width, etc) | if(_(styles).has('stroke') && styles['stroke'] == 'none') {
_(STYLE_PROPERTIES['stroke']).without('stroke');
styles = removeStyleProperties(styles, _(STYLE_PROPERTIES['stroke']).without('stroke'));
} |
if fill:none, then remove all fill-related properties (fill-rule, etc) | if(_(styles).has('fill') && styles['fill'] == 'none') {
styles = removeStyleProperties(styles, _(STYLE_PROPERTIES['fill']).without('fill'));
}
if(_(styles).has('fill-opacity') && parseFloat(styles['fill-opacity']) == 0.0) {
styles = removeStyleProperties(styles, _(STYLE_PROPERTIES['fill']).without('fill-opacity'));
}
if(_(styles).has('stroke-opacity') && parseFloat(styles['stroke-opacity']) == 0.0) {
styles = removeStyleProperties(styles, _(STYLE_PROPERTIES['stroke']).without('stroke-opacity'));
}
if(_(styles).has('stroke-width') && styleValue(styles['stroke-width']) == 0.0) {
styles = removeStyleProperties(styles, _(STYLE_PROPERTIES['stroke']).without('stroke-width'));
} |
scour:
| |
removes all text styles, if node does not contain text | if(!mayContainTextNode(node[0])) {
styles = removeStyleProperties(styles, STYLE_PROPERTIES['text']);
} |
changed: removed style properties with a vendor prefix, like
| var stylesWithVendorPrefixes = _(styles).keys().filter(function(key) {
return _(VENDOR_PREFIXES).any(function(prefix) {
return (key.indexOf(prefix) == 0);
});
});
styles = removeStyleProperties(styles, stylesWithVendorPrefixes); |
@missing: scour also cleans overflow property scour:
| |
scour:
| if(options.styleToAttributes) {
_(styles).each(function(value, key) {
if(!_(SVG_ATTRIBUTES).include(key)) {
return;
}
$(node).attr(key, value);
delete styles[key];
});
}
$node.attr('style', renderStyles(styles));
} |
| |
Interface | var defaultOptions = {
styleToAttributes: true
}
var options = {}; |
scour:
| |
Main processing steps are implemented in SVGCleaner.prototype.clean() | |
This method is to provide a simple interface so one could call
| function clean(svgString, options) {
var aSVGCleaner = new SVGCleaner(options);
aSVGCleaner.load(svgString);
return aSVGCleaner.clean().svgString();
}
module.exports.clean = clean; |
simple interface
| function cleanFileSync(srcFilename, targetFilename, options) {
return new SVGCleaner(options)
.readFileSync(srcFilename)
.writeFileSync(targetFilename)
.svgString()
;
}
module.exports.cleanFile = cleanFileSync;
function SVGCleaner(_options) {
_(options).extend(defaultOptions, _options);
this.$ = cheerio;
return this;
}
module.exports.createCleaner = SVGCleaner;
SVGCleaner.prototype.load = function(svgString) {
this.$ = cheerio.load(svgString, { ignoreWhitespace: true, xmlMode: true });
return this;
}
SVGCleaner.prototype.readFileSync = function(srcFilename) {
return this.load(fs.readFileSync(srcFilename, 'utf-8'));
}
SVGCleaner.prototype.writeFileSync = function(targetFilename) {
fs.writeFileSync(targetFilename, this.svgString(), 'utf-8');
return this;
}
SVGCleaner.prototype.svgString = function() {
return this.$.html();
} |
chainable interface, to perform specific processing steps on an svg object
| var exposed = {
'shortenIDs': shortenIDs,
'removeComments': removeComments,
'repairStyles': repairStyles,
'removeNSElements': removeNamespacedElements,
'removeNSAttributes': removeNamespacedAttributes,
'removeUnreferencedElements': removeUnreferencedElements
};
_(exposed).each(function(f, name) {
SVGCleaner.prototype[name] = function() {
$ = this.$;
f.call();
this.$ = $;
return this;
}
}); |
| |
Processing Steps | SVGCleaner.prototype.clean = function() {
$ = this.$;
removeNamespacedElements(namespacePrefixes);
removeNamespacedAttributes(namespacePrefixes); |
@missing remove the xmlns: declarations now | |
@missing ensure namespace for SVG is declared | |
@missing check for redundant SVG namespace declaration | removeComments(); |
scour:
| repairStyles(); |
@missing convert colors to #RRGGBB format | |
@missing remove | |
@missing scour:
| |
scour:
| removeUnreferencedElements(); |
scour:
| |
@extended: also removes nested empty elements | var elementWasRemoved = true;
do {
elementWasRemoved = false;
_($('defs, metadata, g')).each(function(node) {
if(!node.children.length) {
$(node).remove();
elementWasRemoved = true;
}
});
} while(elementWasRemoved);
removeUnreferencedIDs(); |
@missing: removeDuplicateGradientStops(); | |
@missing scour:
| |
@missing scour:
| |
@missing scour:
| |
@missing scour:
| |
@missing scour:
| |
@missing scour:
| |
@missing scour:
| |
@missing scour:
| |
@missing scour:
| |
scour:
| shortenIDs(); |
@missing scour:
| |
@missing scour:
| |
@missing scour:
| |
@missing scour:
| |
@missing scour:
| |
@missing scour: properly size the SVG document (ideally width/height should be 100% with a viewBox) | this.$ = $;
return this;
}
|