From 2a476e4277d7c531e7b050418168e1097a9e7dba Mon Sep 17 00:00:00 2001 From: Andrea Bogazzi Date: Thu, 23 Aug 2018 01:06:07 +0200 Subject: [PATCH] Clip path parsing (#4786) * first pass done * restarted-clippaths * some changes to element parser * shared attribute * done one piece * cleaned * mmm going there * so far so good * a very first draft * removed dist * sovled conflict * now solved * now solved * some improvements * toObject and fromObject added * toObject and fromObject added * more small changes * added simple tests * bumpedup qunit * a test for svg export * no ist * more svg exporpt * fix lint * make possible to clip canvas * improved JSOCS * no builds * invalidate cache anyway * changes * changes * changes * mmm working --- .travis.yml | 4 +- HEADER.js | 18 ++-- package.json | 2 +- src/elements_parser.js | 171 ++++++++++++++++++++------------ src/mixins/itext.svg_export.js | 2 +- src/mixins/object.svg_export.js | 21 +++- src/parser.js | 25 +++-- src/shapes/circle.class.js | 2 +- src/shapes/ellipse.class.js | 2 +- src/shapes/group.class.js | 7 +- src/shapes/image.class.js | 15 +-- src/shapes/line.class.js | 2 +- src/shapes/object.class.js | 109 ++++++++++++++++---- src/shapes/path.class.js | 2 +- src/shapes/polyline.class.js | 2 +- src/shapes/rect.class.js | 2 +- src/shapes/triangle.class.js | 2 +- src/static_canvas.class.js | 50 ++++++++-- src/util/misc.js | 14 +++ test/unit/image.js | 4 +- test/unit/object_clipPath.js | 88 ++++++++++++++++ test/unit/rect.js | 2 +- test/unit/textbox.js | 2 +- 23 files changed, 408 insertions(+), 140 deletions(-) create mode 100644 test/unit/object_clipPath.js diff --git a/.travis.yml b/.travis.yml index 0f09a9c5ee1..a5dd827fda2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -50,13 +50,13 @@ jobs: packages: # avoid installing packages - stage: Unit Tests env: LAUNCHER=Chrome - install: npm install testem@1.18.4 qunit@2.4.1 + install: npm install testem@1.18.4 qunit@2.6.1 addons: apt: packages: # avoid installing packages - stage: Unit Tests env: LAUNCHER=Firefox - install: npm install testem@1.18.4 qunit@2.4.1 + install: npm install testem@1.18.4 qunit@2.6.1 addons: apt: packages: # avoid installing packages diff --git a/HEADER.js b/HEADER.js index c996e58c8b2..b3a5e43e876 100644 --- a/HEADER.js +++ b/HEADER.js @@ -47,15 +47,15 @@ fabric.isLikelyNode = typeof Buffer !== 'undefined' && * @type array */ fabric.SHARED_ATTRIBUTES = [ - "display", - "transform", - "fill", "fill-opacity", "fill-rule", - "opacity", - "stroke", "stroke-dasharray", "stroke-linecap", - "stroke-linejoin", "stroke-miterlimit", - "stroke-opacity", "stroke-width", - "id", "paint-order", - "instantiated_by_use" + 'display', + 'transform', + 'fill', 'fill-opacity', 'fill-rule', + 'opacity', + 'stroke', 'stroke-dasharray', 'stroke-linecap', + 'stroke-linejoin', 'stroke-miterlimit', + 'stroke-opacity', 'stroke-width', + 'id', 'paint-order', + 'instantiated_by_use', 'clip-path' ]; /* _FROM_SVG_END_ */ diff --git a/package.json b/package.json index 93b8010e19b..c13da032379 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "eslint": "4.18.x", "istanbul": "0.4.x", "onchange": "^3.x.x", - "qunit": "^2.4.1", + "qunit": "^2.6.1", "testem": "^1.18.4", "uglify-js": "3.3.x", "pixelmatch": "^4.0.2" diff --git a/src/elements_parser.js b/src/elements_parser.js index 7a212ba048c..61cba798ace 100644 --- a/src/elements_parser.js +++ b/src/elements_parser.js @@ -8,79 +8,122 @@ fabric.ElementsParser = function(elements, callback, options, reviver, parsingOp this.regexUrl = /^url\(['"]?#([^'"]+)['"]?\)/g; }; -fabric.ElementsParser.prototype.parse = function() { - this.instances = new Array(this.elements.length); - this.numElements = this.elements.length; +(function(proto) { + proto.parse = function() { + this.instances = new Array(this.elements.length); + this.numElements = this.elements.length; + this.createObjects(); + }; - this.createObjects(); -}; + proto.createObjects = function() { + var _this = this; + this.elements.forEach(function(element, i) { + element.setAttribute('svgUid', _this.svgUid); + _this.createObject(element, i); + }); + }; -fabric.ElementsParser.prototype.createObjects = function() { - for (var i = 0, len = this.elements.length; i < len; i++) { - this.elements[i].setAttribute('svgUid', this.svgUid); - (function(_obj, i) { - setTimeout(function() { - _obj.createObject(_obj.elements[i], i); - }, 0); - })(this, i); - } -}; + proto.findTag = function(el) { + return fabric[fabric.util.string.capitalize(el.tagName.replace('svg:', ''))]; + }; -fabric.ElementsParser.prototype.createObject = function(el, index) { - var klass = fabric[fabric.util.string.capitalize(el.tagName.replace('svg:', ''))]; - if (klass && klass.fromElement) { - try { - this._createObject(klass, el, index); + proto.createObject = function(el, index) { + var klass = this.findTag(el); + if (klass && klass.fromElement) { + try { + klass.fromElement(el, this.createCallback(index, el), this.options); + } + catch (err) { + fabric.log(err); + } } - catch (err) { - fabric.log(err); + else { + this.checkIfDone(); } - } - else { - this.checkIfDone(); - } -}; + }; -fabric.ElementsParser.prototype._createObject = function(klass, el, index) { - klass.fromElement(el, this.createCallback(index, el), this.options); -}; + proto.createCallback = function(index, el) { + var _this = this; + return function(obj) { + var _options; + _this.resolveGradient(obj, 'fill'); + _this.resolveGradient(obj, 'stroke'); + if (obj instanceof fabric.Image) { + _options = obj.parsePreserveAspectRatioAttribute(el); + } + obj._removeTransformMatrix(_options); + _this.resolveClipPath(obj); + _this.reviver && _this.reviver(el, obj); + _this.instances[index] = obj; + _this.checkIfDone(); + }; + }; -fabric.ElementsParser.prototype.createCallback = function(index, el) { - var _this = this; - return function(obj) { - var _options; - _this.resolveGradient(obj, 'fill'); - _this.resolveGradient(obj, 'stroke'); - if (obj instanceof fabric.Image) { - _options = obj.parsePreserveAspectRatioAttribute(el); + proto.extractPropertyDefinition = function(obj, property, storage) { + var value = obj[property]; + if (!(/^url\(/).test(value)) { + return; } - obj._removeTransformMatrix(_options); - _this.reviver && _this.reviver(el, obj); - _this.instances[index] = obj; - _this.checkIfDone(); + var id = this.regexUrl.exec(value)[1]; + this.regexUrl.lastIndex = 0; + return fabric[storage][this.svgUid][id]; }; -}; -fabric.ElementsParser.prototype.resolveGradient = function(obj, property) { + proto.resolveGradient = function(obj, property) { + var gradientDef = this.extractPropertyDefinition(obj, property, 'gradientDefs'); + if (gradientDef) { + obj.set(property, fabric.Gradient.fromElement(gradientDef, obj)); + } + }; - var instanceFillValue = obj[property]; - if (!(/^url\(/).test(instanceFillValue)) { - return; - } - var gradientId = this.regexUrl.exec(instanceFillValue)[1]; - this.regexUrl.lastIndex = 0; - if (fabric.gradientDefs[this.svgUid][gradientId]) { - obj.set(property, - fabric.Gradient.fromElement(fabric.gradientDefs[this.svgUid][gradientId], obj)); - } -}; + proto.createClipPathCallback = function(obj, container) { + return function(_newObj) { + _newObj._removeTransformMatrix(); + _newObj.fillRule = _newObj.clipRule; + container.push(_newObj); + }; + }; -fabric.ElementsParser.prototype.checkIfDone = function() { - if (--this.numElements === 0) { - this.instances = this.instances.filter(function(el) { - // eslint-disable-next-line no-eq-null, eqeqeq - return el != null; - }); - this.callback(this.instances, this.elements); - } -}; + proto.resolveClipPath = function(obj) { + var clipPath = this.extractPropertyDefinition(obj, 'clipPath', 'clipPaths'), + element, klass, objTransformInv, container, gTransform, options; + if (clipPath) { + container = []; + objTransformInv = fabric.util.invertTransform(obj.calcTransformMatrix()); + for (var i = 0; i < clipPath.length; i++) { + element = clipPath[i]; + klass = this.findTag(element); + klass.fromElement( + element, + this.createClipPathCallback(obj, container), + this.options + ); + } + clipPath = new fabric.Group(container); + gTransform = fabric.util.multiplyTransformMatrices( + objTransformInv, + clipPath.calcTransformMatrix() + ); + var options = fabric.util.qrDecompose(gTransform); + clipPath.flipX = false; + clipPath.flipY = false; + clipPath.set('scaleX', options.scaleX); + clipPath.set('scaleY', options.scaleY); + clipPath.angle = options.angle; + clipPath.skewX = options.skewX; + clipPath.skewY = 0; + clipPath.setPositionByOrigin({ x: options.translateX, y: options.translateY }, 'center', 'center'); + obj.clipPath = clipPath; + } + }; + + proto.checkIfDone = function() { + if (--this.numElements === 0) { + this.instances = this.instances.filter(function(el) { + // eslint-disable-next-line no-eq-null, eqeqeq + return el != null; + }); + this.callback(this.instances, this.elements); + } + }; +})(fabric.ElementsParser.prototype); diff --git a/src/mixins/itext.svg_export.js b/src/mixins/itext.svg_export.js index 7458cf97fb0..307e77be5c1 100644 --- a/src/mixins/itext.svg_export.js +++ b/src/mixins/itext.svg_export.js @@ -38,7 +38,7 @@ style = filter === '' ? '' : ' style="' + filter + '"', textDecoration = this.getSvgTextDecoration(this); markup.push( - '\t\n', textAndBg.textBgRects.join(''), '\t\t\n\t', + this.clipPath.toSVG(), + '\n' + ); + } return markup; }, diff --git a/src/parser.js b/src/parser.js index 39e7fe1bc63..de9ad84ee12 100644 --- a/src/parser.js +++ b/src/parser.js @@ -15,10 +15,10 @@ multiplyTransformMatrices = fabric.util.multiplyTransformMatrices, svgValidTagNames = ['path', 'circle', 'polygon', 'polyline', 'ellipse', 'rect', 'line', - 'image', 'text', 'linearGradient', 'radialGradient', 'stop'], + 'image', 'text'], svgViewBoxElements = ['symbol', 'image', 'marker', 'pattern', 'view', 'svg'], svgInvalidAncestors = ['pattern', 'defs', 'symbol', 'metadata', 'clipPath', 'mask', 'desc'], - svgValidParents = ['symbol', 'g', 'a', 'svg'], + svgValidParents = ['symbol', 'g', 'a', 'svg', 'clipPath', 'defs'], attributesMap = { cx: 'left', @@ -45,7 +45,9 @@ 'stroke-width': 'strokeWidth', 'text-decoration': 'textDecoration', 'text-anchor': 'textAnchor', - opacity: 'opacity' + opacity: 'opacity', + 'clip-path': 'clipPath', + 'clip-rule': 'clipRule', }, colorAttributes = { @@ -60,6 +62,7 @@ fabric.cssRules = { }; fabric.gradientDefs = { }; + fabric.clipPaths = { }; function normalizeAttr(attr) { // transform attribute names @@ -617,7 +620,7 @@ scaleY + ' ' + (minX * scaleX + widthDiff) + ' ' + (minY * scaleY + heightDiff) + ') '; - + parsedDim.viewboxTransform = fabric.parseTransformAttribute(matrix); if (element.nodeName === 'svg') { el = element.ownerDocument.createElement('g'); // element.firstChild != null @@ -630,7 +633,6 @@ el = element; matrix = el.getAttribute('transform') + matrix; } - el.setAttribute('transform', matrix); return parsedDim; } @@ -691,13 +693,24 @@ callback && callback([], {}); return; } - + var clipPaths = { }; + descendants.filter(function(el) { + return el.nodeName.replace('svg:', '') === 'clipPath'; + }).forEach(function(el) { + clipPaths[el.id] = fabric.util.toArray(el.getElementsByTagName('*')).filter(function(el) { + return fabric.svgValidTagNamesRegEx.test(el.nodeName.replace('svg:', '')); + }); + }); fabric.gradientDefs[svgUid] = fabric.getGradientDefs(doc); fabric.cssRules[svgUid] = fabric.getCSSRules(doc); + fabric.clipPaths[svgUid] = clipPaths; // Precedence of rules: style > class > attribute fabric.parseElements(elements, function(instances, elements) { if (callback) { callback(instances, options, elements, descendants); + delete fabric.gradientDefs[svgUid]; + delete fabric.cssRules[svgUid]; + delete fabric.clipPaths[svgUid]; } }, clone(options), reviver, parsingOptions); }; diff --git a/src/shapes/circle.class.js b/src/shapes/circle.class.js index 6134f7d6194..25f7a4fc56c 100644 --- a/src/shapes/circle.class.js +++ b/src/shapes/circle.class.js @@ -89,7 +89,7 @@ if (angle === 0) { markup.push( - '\n'); - var imageMarkup = ['\t element to initialize instance on @@ -900,11 +909,11 @@ * @chainable */ renderCanvas: function(ctx, objects) { - var v = this.viewportTransform; + var v = this.viewportTransform, path = this.clipPath; this.cancelRequestedRender(); this.calcViewportBoundaries(); this.clearContext(ctx); - this.fire('before:render'); + this.fire('before:render', { ctx: ctx, }); if (this.clipTo) { fabric.util.clipContext(this, ctx); } @@ -921,11 +930,38 @@ if (this.clipTo) { ctx.restore(); } + if (path) { + if (path.isCacheDirty()) { + // needed to setup a couple of variables + path.shouldCache(); + path.canvas = this; + path._transformDone = true; + path.renderCache({ forClipping: true }); + } + this.drawClipPathOnCanvas(ctx); + } this._renderOverlay(ctx); if (this.controlsAboveOverlay && this.interactive) { this.drawControls(ctx); } - this.fire('after:render'); + this.fire('after:render', { ctx: ctx, }); + }, + + /** + * Paint the cached clipPath on the lowerCanvasEl + * @param {CanvasRenderingContext2D} ctx Context to render on + */ + drawClipPathOnCanvas: function(ctx) { + var v = this.viewportTransform, path = this.clipPath; + ctx.save(); + ctx.transform(v[0], v[1], v[2], v[3], v[4], v[5]); + // DEBUG: uncomment this line, comment the following + // ctx.globalAlpha = 0.4 + ctx.globalCompositeOperation = 'destination-in'; + path.transform(ctx); + ctx.scale(1 / path.zoomX, 1 / path.zoomY); + ctx.drawImage(path._cacheCanvas, -path.cacheTranslationX, -path.cacheTranslationY); + ctx.restore(); }, /** @@ -1122,11 +1158,13 @@ */ _toObjectMethod: function (methodName, propertiesToInclude) { - var data = { + var clipPath = this.clipPath, data = { version: fabric.version, - objects: this._toObjects(methodName, propertiesToInclude) + objects: this._toObjects(methodName, propertiesToInclude), }; - + if (clipPath) { + clipPath = clipPath.toObject(propertiesToInclude); + } extend(data, this.__serializeBgOverlay(methodName, propertiesToInclude)); fabric.util.populateWithProperties(this, data, propertiesToInclude); diff --git a/src/util/misc.js b/src/util/misc.js index 923d8323a13..9d3ef3b9ce8 100644 --- a/src/util/misc.js +++ b/src/util/misc.js @@ -581,6 +581,20 @@ return fabric.document.createElement('canvas'); }, + /** + * Creates a canvas element that is a copy of another and is also painted + * @static + * @memberOf fabric.util + * @return {CanvasElement} initialized canvas element + */ + copyCanvasElement: function(canvas) { + var newCanvas = fabric.document.createElement('canvas'); + newCanvas.width = canvas.width; + newCanvas.height = canvas.height; + newCanvas.getContext('2d').drawImage(canvas, 0, 0); + return newCanvas; + }, + /** * Creates image element (works on client and node) * @static diff --git a/test/unit/image.js b/test/unit/image.js index 40a6c155f7c..72e1695840f 100644 --- a/test/unit/image.js +++ b/test/unit/image.js @@ -675,13 +675,13 @@ }); }); - QUnit.test('apply filters do not set the image dirty if not in group', function(assert) { + QUnit.test('apply filters set the image dirty', function(assert) { var done = assert.async(); createImageObject(function(image) { image.dirty = false; assert.equal(image.dirty, false, 'false apply filter dirty is false'); image.applyFilters(); - assert.equal(image.dirty, false, 'After apply filter dirty is true'); + assert.equal(image.dirty, true, 'After apply filter dirty is true'); done(); }); }); diff --git a/test/unit/object_clipPath.js b/test/unit/object_clipPath.js new file mode 100644 index 00000000000..26146caa02c --- /dev/null +++ b/test/unit/object_clipPath.js @@ -0,0 +1,88 @@ +(function(){ + + // var canvas = this.canvas = new fabric.StaticCanvas(null, {enableRetinaScaling: false}); + + QUnit.module('fabric.Object - clipPath', { + afterEach: function() { + // canvas.clear(); + // canvas.calcOffset(); + } + }); + + QUnit.test('constructor & properties', function(assert) { + var cObj = new fabric.Object(); + assert.equal(cObj.clipPath, undefined, 'clipPath should not be defined out of the box'); + }); + + QUnit.test('toObject with clipPath', function(assert) { + var emptyObjectRepr = { + 'version': fabric.version, + 'type': 'object', + 'originX': 'left', + 'originY': 'top', + 'left': 0, + 'top': 0, + 'width': 0, + 'height': 0, + 'fill': 'rgb(0,0,0)', + 'stroke': null, + 'strokeWidth': 1, + 'strokeDashArray': null, + 'strokeLineCap': 'butt', + 'strokeLineJoin': 'miter', + 'strokeMiterLimit': 4, + 'scaleX': 1, + 'scaleY': 1, + 'angle': 0, + 'flipX': false, + 'flipY': false, + 'opacity': 1, + 'shadow': null, + 'visible': true, + 'backgroundColor': '', + 'clipTo': null, + 'fillRule': 'nonzero', + 'paintFirst': 'fill', + 'globalCompositeOperation': 'source-over', + 'skewX': 0, + 'skewY': 0, + 'transformMatrix': null + }; + + var cObj = new fabric.Object(); + assert.deepEqual(emptyObjectRepr, cObj.toObject()); + + cObj.clipPath = new fabric.Object(); + + var expected = fabric.util.object.clone(emptyObjectRepr); + expected.clipPath = emptyObjectRepr; + assert.deepEqual(expected, cObj.toObject()); + }); + + QUnit.test('from object with clipPath', function(assert) { + var done = assert.async(); + var rect = new fabric.Rect({ width: 100, height: 100 }); + rect.clipPath = new fabric.Circle({ radius: 50 }); + var toObject = rect.toObject(); + fabric.Rect.fromObject(toObject, function(rect) { + assert.ok(rect.clipPath instanceof fabric.Circle, 'clipPath is enlived'); + assert.equal(rect.clipPath.radius, 50, 'radius is restored correctly'); + done(); + }); + }); + + QUnit.test('from object with clipPath, nested', function(assert) { + var done = assert.async(); + var rect = new fabric.Rect({ width: 100, height: 100 }); + rect.clipPath = new fabric.Circle({ radius: 50 }); + rect.clipPath.clipPath = new fabric.Text('clipPath'); + var toObject = rect.toObject(); + fabric.Rect.fromObject(toObject, function(rect) { + assert.ok(rect.clipPath instanceof fabric.Circle, 'clipPath is enlived'); + assert.equal(rect.clipPath.radius, 50, 'radius is restored correctly'); + assert.ok(rect.clipPath.clipPath instanceof fabric.Text, 'neted clipPath is enlived'); + assert.equal(rect.clipPath.clipPath.text, 'clipPath', 'instance is restored correctly'); + done(); + }); + }); +})(); diff --git a/test/unit/rect.js b/test/unit/rect.js index eb6474e756d..1a23ce5a727 100644 --- a/test/unit/rect.js +++ b/test/unit/rect.js @@ -33,7 +33,7 @@ 'rx': 0, 'ry': 0, 'skewX': 0, - 'skewY': 0, + 'skewY': 0 }; QUnit.module('fabric.Rect'); diff --git a/test/unit/textbox.js b/test/unit/textbox.js index dddca4736dd..d2aa7bac666 100644 --- a/test/unit/textbox.js +++ b/test/unit/textbox.js @@ -51,7 +51,7 @@ transformMatrix: null, charSpacing: 0, styles: { }, - minWidth: 20, + minWidth: 20 }; QUnit.test('constructor', function(assert) {