From 513233bf7836a1863107e2fba92b24e5240c716a Mon Sep 17 00:00:00 2001 From: Andrea Bogazzi Date: Sun, 16 Sep 2018 01:59:36 +0200 Subject: [PATCH] Making clipPath absolute positionable (#5199) Added visual tests Added property absolutePositioned and inverse --- .eslintrc_tests | 3 +- .travis.yml | 10 +- package.json | 10 +- src/shapes/object.class.js | 43 ++- test/lib/visualCallbackQunit.js | 41 +++ test/node_test_setup.js | 23 +- test/unit/object_clipPath.js | 113 +++++--- test/visual/assets/svg_linear_8.svg | 4 +- test/visual/clippath.js | 418 ++++++++++++++++++++++++++++ test/visual/golden/clipping0.png | Bin 0 -> 1347 bytes test/visual/golden/clipping01.png | Bin 0 -> 1938 bytes test/visual/golden/clipping1.png | Bin 0 -> 1646 bytes test/visual/golden/clipping2.png | Bin 0 -> 3842 bytes test/visual/golden/clipping3.png | Bin 0 -> 5205 bytes test/visual/golden/clipping4.png | Bin 0 -> 2283 bytes test/visual/golden/clipping5.png | Bin 0 -> 3830 bytes test/visual/golden/clipping6.png | Bin 0 -> 5124 bytes test/visual/golden/clipping7.png | Bin 0 -> 4291 bytes test/visual/golden/clipping8.png | Bin 0 -> 2243 bytes test/visual/golden/clipping9.png | Bin 0 -> 2533 bytes test/visual/golden/svg_linear_8.png | Bin 2343 -> 2066 bytes test/visual/resize_filter.js | 28 +- test/visual/svg_import.js | 33 ++- testem-visual.json | 1 + 24 files changed, 663 insertions(+), 64 deletions(-) create mode 100644 test/lib/visualCallbackQunit.js create mode 100644 test/visual/clippath.js create mode 100644 test/visual/golden/clipping0.png create mode 100644 test/visual/golden/clipping01.png create mode 100644 test/visual/golden/clipping1.png create mode 100644 test/visual/golden/clipping2.png create mode 100644 test/visual/golden/clipping3.png create mode 100644 test/visual/golden/clipping4.png create mode 100644 test/visual/golden/clipping5.png create mode 100644 test/visual/golden/clipping6.png create mode 100644 test/visual/golden/clipping7.png create mode 100644 test/visual/golden/clipping8.png create mode 100644 test/visual/golden/clipping9.png diff --git a/.eslintrc_tests b/.eslintrc_tests index 7b7d9f95..39d48ab8 100644 --- a/.eslintrc_tests +++ b/.eslintrc_tests @@ -6,7 +6,8 @@ "globals": { "fabric": true, "QUnit": true, - "assert": true + "assert": true, + "pixelmatch": true }, "rules": { "eqeqeq": 0, diff --git a/.travis.yml b/.travis.yml index a5dd827f..5301fcd0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,7 @@ addons: - libpng-dev - libpango1.0-dev - libjpeg-dev + - librsvg2-dev # libcairo2-dev is preinstalled stages: - Linting and Building @@ -50,12 +51,14 @@ jobs: packages: # avoid installing packages - stage: Unit Tests env: LAUNCHER=Chrome + script: npm run build:fast && testem ci --port 8080 -f testem.json -l $LAUNCHER install: npm install testem@1.18.4 qunit@2.6.1 addons: apt: packages: # avoid installing packages - stage: Unit Tests env: LAUNCHER=Firefox + script: npm run build:fast && testem ci --port 8080 -f testem.json -l $LAUNCHER install: npm install testem@1.18.4 qunit@2.6.1 addons: apt: @@ -72,17 +75,18 @@ jobs: - stage: Unit Tests node_js: "4" - stage: Visual Tests + env: LAUNCHER=Node CANFAIL=TRUE node_js: "8" script: npm run build:fast && npm run test:visual - stage: Visual 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 script: npm run build:fast && testem ci --port 8080 -f testem-visual.json -l $LAUNCHER - stage: Visual 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 script: npm run build:fast && testem ci --port 8080 -f testem-visual.json -l $LAUNCHER -script: 'npm run build:fast && testem ci --port 8080 -f testem.json -l $LAUNCHER' +script: npm run build:fast && npm run test dist: trusty diff --git a/package.json b/package.json index c13da032..476f4e9a 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,10 @@ "test:single": "./node_modules/qunit/bin/qunit test/node_test_setup.js test/lib", "test": "istanbul cover ./node_modules/qunit/bin/qunit test/node_test_setup.js test/lib test/unit", "test:visual": "./node_modules/qunit/bin/qunit test/node_test_setup.js test/lib test/visual", + "test:visual:single": "./node_modules/qunit/bin/qunit test/node_test_setup.js test/lib", "test:all": "npm run test && npm run test:visual", "lint": "eslint --config .eslintrc.json src", - "lint_tests": "eslint test/unit --config .eslintrc_tests", + "lint_tests": "eslint test/unit --config .eslintrc_tests && eslint test/visual --config .eslintrc_tests", "export_dist_to_site": "cp dist/fabric.js ../fabricjs.com/lib/fabric.js && cp package.json ../fabricjs.com/lib/package.json && cp -r src HEADER.js lib ../fabricjs.com/build/files/", "export_tests_to_site": "cp test/unit/*.js ../fabricjs.com/test/unit && cp -r test/visual/* ../fabricjs.com/test/visual && cp -r test/fixtures/* ../fabricjs.com/test/fixtures", "all": "npm run build && npm run test && npm run test:visual && npm run lint && npm run lint_tests && npm run export_dist_to_site && npm run export_tests_to_site", @@ -59,8 +60,8 @@ "testem:ci": "testem ci" }, "optionalDependencies": { - "canvas": "1.6.x", - "jsdom": "9.x.x", + "canvas": "^1.6.12", + "jsdom": "^9.12.0", "xmldom": "0.1.x" }, "devDependencies": { @@ -70,7 +71,8 @@ "qunit": "^2.6.1", "testem": "^1.18.4", "uglify-js": "3.3.x", - "pixelmatch": "^4.0.2" + "pixelmatch": "^4.0.2", + "chalk": "^2.4.1" }, "engines": { "node": ">=4.0.0" diff --git a/src/shapes/object.class.js b/src/shapes/object.class.js index 6f06947a..803c9f54 100644 --- a/src/shapes/object.class.js +++ b/src/shapes/object.class.js @@ -612,6 +612,27 @@ */ clipPath: undefined, + /** + * Meaningfull ONLY when the object is used as clipPath. + * if true, the clipPath will make the object clip to the outside of the clipPath + * since 2.4.0 + * @type boolean + * @default false + */ + inverted: false, + + /** + * Meaningfull ONLY when the object is used as clipPath. + * if true, the clipPath will have its top and left relative to canvas, and will + * not be influenced by the object transform. This will make the clipPath relative + * to the canvas, but clipping just a particular object. + * WARNING this is beta, this feature may change or be renamed. + * since 2.4.0 + * @type boolean + * @default false + */ + absolutePositioned: false, + /** * Constructor * @param {Object} [options] Options object @@ -842,6 +863,8 @@ if (this.clipPath) { object.clipPath = this.clipPath.toObject(propertiesToInclude); + object.clipPath.inverted = this.clipPath.inverted; + object.clipPath.absolutePositioned = this.clipPath.absolutePositioned; } fabric.util.populateWithProperties(this, object, propertiesToInclude); @@ -1120,8 +1143,17 @@ ctx.save(); // DEBUG: uncomment this line, comment the following // ctx.globalAlpha = 0.4 - ctx.globalCompositeOperation = 'destination-in'; + if (path.inverted) { + ctx.globalCompositeOperation = 'destination-out'; + } + else { + ctx.globalCompositeOperation = 'destination-in'; + } //ctx.scale(1 / 2, 1 / 2); + if (path.absolutePositioned) { + var m = fabric.util.invertTransform(this.calcTransformMatrix()); + ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5]); + } path.transform(ctx); ctx.scale(1 / path.zoomX, 1 / path.zoomY); ctx.drawImage(path._cacheCanvas, -path.cacheTranslationX, -path.cacheTranslationY); @@ -1182,7 +1214,10 @@ return true; } else { - if (this.dirty || (this.statefullCache && this.hasStateChanged('cacheProperties'))) { + if (this.dirty || + (this.clipPath && this.clipPath.absolutePositioned) || + (this.statefullCache && this.hasStateChanged('cacheProperties')) + ) { if (this._cacheCanvas && !skipCanvas) { var width = this.cacheWidth / this.zoomX; var height = this.cacheHeight / this.zoomY; @@ -1252,8 +1287,8 @@ _setClippingProperties: function(ctx) { ctx.globalAlpha = 1; - ctx.lineWidth = 0; - ctx.fillStyle = 'black'; + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = '#000000'; }, /** diff --git a/test/lib/visualCallbackQunit.js b/test/lib/visualCallbackQunit.js new file mode 100644 index 00000000..b987a46b --- /dev/null +++ b/test/lib/visualCallbackQunit.js @@ -0,0 +1,41 @@ +(function(window) { + function visualCallback() { + this.currentArgs = {}; + } + + visualCallback.prototype.addArguments = function(argumentObj) { + this.currentArgs = { + enabled: true, + fabric: argumentObj.fabric, + golden: argumentObj.golden, + diff: argumentObj.diff, + }; + }; + + visualCallback.prototype.testDone = function(details) { + if (window && document && this.currentArgs.enabled) { + var fabricCanvas = this.currentArgs.fabric; + var ouputImageDataRef = this.currentArgs.diff; + var goldenCanvasRef = this.currentArgs.golden; + var id = 'qunit-test-output-' + details.testId; + var node = document.getElementById(id); + var fabricCopy = document.createElement('canvas'); + var diff = document.createElement('canvas'); + diff.width = fabricCopy.width = fabricCanvas.width; + diff.height = fabricCopy.height = fabricCanvas.height; + diff.getContext('2d').putImageData(ouputImageDataRef, 0, 0); + fabricCopy.getContext('2d').drawImage(fabricCanvas, 0, 0); + var _div = document.createElement('div'); + _div.appendChild(goldenCanvasRef); + _div.appendChild(fabricCopy); + _div.appendChild(diff); + node.appendChild(_div); + // after one run, disable + this.currentArgs.enabled = false; + } + }; + + if (window) { + window.visualCallback = new visualCallback(); + } +})(this); diff --git a/test/node_test_setup.js b/test/node_test_setup.js index 1d2a50e5..7542ca7e 100644 --- a/test/node_test_setup.js +++ b/test/node_test_setup.js @@ -1,8 +1,29 @@ // set the fabric famework as a global for tests +var chalk = require('chalk'); global.fabric = require('../dist/fabric').fabric; global.pixelmatch = require('pixelmatch'); global.fs = require('fs'); - +global.visualCallback = { + addArguments: function() {}, +}; +global.imageDataToChalk = function(imageData) { + // actually this does not work on travis-ci, so commenting it out + return ''; + var block = String.fromCharCode(9608) + var data = imageData.data; + var width = imageData.width; + var height = imageData.height; + var outputString = ''; + var cp = 0; + for (var i = 0; i < height; i++) { + outputString += '\n'; + for (var j = 0; j < width; j++) { + cp = (i * width + j) * 4; + outputString += chalk.rgb(data[cp], data[cp + 1], data[cp + 2])(block); + } + } + return outputString; +}; QUnit.config.testTimeout = 15000; QUnit.config.noglobals = true; QUnit.config.hidePassed = true; diff --git a/test/unit/object_clipPath.js b/test/unit/object_clipPath.js index 26146caa..a434e22b 100644 --- a/test/unit/object_clipPath.js +++ b/test/unit/object_clipPath.js @@ -16,46 +16,50 @@ 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 + 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; + var expectedClipPath = fabric.util.object.clone(emptyObjectRepr); + expectedClipPath = fabric.util.object.extend(expectedClipPath, { + inverted: cObj.clipPath.inverted, + absolutePositioned: cObj.clipPath.absolutePositioned, + }); + expected.clipPath = expectedClipPath; assert.deepEqual(expected, cObj.toObject()); }); @@ -71,6 +75,20 @@ }); }); + QUnit.test('from object with clipPath inverted, absolutePositioned', function(assert) { + var done = assert.async(); + var rect = new fabric.Rect({ width: 100, height: 100 }); + rect.clipPath = new fabric.Circle({ radius: 50, inverted: true, absolutePositioned: true }); + 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.equal(rect.clipPath.inverted, true, 'inverted is restored correctly'); + assert.equal(rect.clipPath.absolutePositioned, true, 'absolutePositioned 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 }); @@ -85,4 +103,33 @@ done(); }); }); + + QUnit.test('from object with clipPath, nested inverted, absolutePositioned', 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', { inverted: true, absolutePositioned: true}); + 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'); + assert.equal(rect.clipPath.clipPath.inverted, true, 'instance inverted is restored correctly'); + assert.equal(rect.clipPath.clipPath.absolutePositioned, true, 'instance absolutePositioned is restored correctly'); + done(); + }); + }); + + QUnit.test('_setClippingProperties fix the context props', function(assert) { + var canvas = new fabric.Canvas(); + var rect = new fabric.Rect({ width: 100, height: 100 }); + canvas.contextContainer.fillStyle = 'red'; + canvas.contextContainer.strokeStyle = 'blue'; + canvas.contextContainer.globalAlpha = 0.3; + rect._setClippingProperties(canvas.contextContainer); + assert.equal(canvas.contextContainer.fillStyle, '#000000', 'fillStyle is reset'); + assert.equal(new fabric.Color(canvas.contextContainer.strokeStyle).getAlpha(), 0, 'stroke style is reset'); + assert.equal(canvas.contextContainer.globalAlpha, 1, 'globalAlpha is reset'); + }); })(); diff --git a/test/visual/assets/svg_linear_8.svg b/test/visual/assets/svg_linear_8.svg index fe21f47f..d8affc9d 100644 --- a/test/visual/assets/svg_linear_8.svg +++ b/test/visual/assets/svg_linear_8.svg @@ -8,6 +8,6 @@ - - \ No newline at end of file + diff --git a/test/visual/clippath.js b/test/visual/clippath.js new file mode 100644 index 00000000..0c5482e8 --- /dev/null +++ b/test/visual/clippath.js @@ -0,0 +1,418 @@ +(function() { + fabric.enableGLFiltering = false; + fabric.isWebglSupported = false; + fabric.Object.prototype.objectCaching = true; + var _pixelMatch; + var visualCallback; + var fs; + var imageDataToChalk; + if (fabric.isLikelyNode) { + fs = global.fs; + _pixelMatch = global.pixelmatch; + visualCallback = global.visualCallback; + imageDataToChalk = global.imageDataToChalk; + } + else { + _pixelMatch = pixelmatch; + if (window) { + visualCallback = window.visualCallback; + } + imageDataToChalk = function() { return ''; }; + } + var fabricCanvas = this.canvas = new fabric.Canvas(null, { + enableRetinaScaling: false, renderOnAddRemove: false, width: 200, height: 200, + }); + var pixelmatchOptions = { + includeAA: false, + threshold: 0.095 + }; + + function getAbsolutePath(path) { + var isAbsolute = /^https?:/.test(path); + if (isAbsolute) { return path; }; + var imgEl = fabric.document.createElement('img'); + imgEl.src = path; + var src = imgEl.src; + imgEl = null; + return src; + } + + // function getFixtureName(filename) { + // var finalName = '/fixtures/' + filename; + // return fabric.isLikelyNode ? (__dirname + '/..' + finalName) : getAbsolutePath('/test' + finalName); + // } + + function getGoldeName(filename) { + var finalName = '/golden/' + filename; + return fabric.isLikelyNode ? (__dirname + finalName) : getAbsolutePath('/test/visual' + finalName); + } + + function getImage(filename, original, callback) { + if (fabric.isLikelyNode && original) { + try { + fs.statSync(filename); + } + catch (err) { + var dataUrl = original.toDataURL().split(',')[1]; + console.log('creating original for ', filename); + fs.writeFileSync(filename, dataUrl, { encoding: 'base64' }); + } + } + var img = fabric.document.createElement('img'); + img.onload = function() { + img.onload = null; + callback(img); + }; + img.onerror = function(err) { + img.onerror = null; + callback(img); + console.log('Image loading errored', err); + }; + img.src = filename; + } + + function beforeEachHandler() { + fabricCanvas.clipPath = null; + fabricCanvas.viewportTransform = [1, 0, 0, 1, 0, 0]; + fabricCanvas.clear(); + fabricCanvas.renderAll(); + } + + var tests = []; + + function clipping0(canvas, callback) { + var clipPath = new fabric.Circle({ radius: 100, strokeWidth: 0, top: -10, left: -10 }); + var obj = new fabric.Rect({ top: 0, left: 0, strokeWidth: 0, width: 200, height: 200, fill: 'rgba(0,255,0,0.5)'}); + obj.clipPath = clipPath; + canvas.add(obj); + canvas.renderAll(); + callback(canvas.lowerCanvasEl); + } + + tests.push({ + test: 'Clip a rect with a circle, no zoom', + code: clipping0, + golden: 'clipping0.png', + newModule: 'Clipping shapes', + percentage: 0.06, + }); + + function clipping01(canvas, callback) { + var clipPath = new fabric.Circle({ radius: 50, strokeWidth: 40, top: -50, left: -50, fill: 'transparent' }); + var obj = new fabric.Rect({ top: 0, left: 0, strokeWidth: 0, width: 200, height: 200, fill: 'rgba(0,255,0,0.5)'}); + obj.clipPath = clipPath; + canvas.add(obj); + canvas.renderAll(); + callback(canvas.lowerCanvasEl); + } + + tests.push({ + test: 'A clippath ignores fill and stroke for drawing, not positioning', + code: clipping01, + golden: 'clipping01.png', + newModule: 'Clipping shapes', + percentage: 0.06, + }); + + function clipping1(canvas, callback) { + var zoom = 20; + canvas.setZoom(zoom); + var clipPath = new fabric.Circle({ radius: 5, strokeWidth: 0, top: -2, left: -2 }); + var obj = new fabric.Rect({ top: 0, left: 0, strokeWidth: 0, width: 10, height: 10, fill: 'rgba(255,0,0,0.5)'}); + obj.clipPath = clipPath; + canvas.add(obj); + canvas.renderAll(); + callback(canvas.lowerCanvasEl); + } + + tests.push({ + test: 'Clip a rect with a circle, with zoom', + code: clipping1, + golden: 'clipping1.png', + percentage: 0.06, + }); + + function clipping2(canvas, callback) { + var clipPath = new fabric.Circle({ + radius: 100, + top: -100, + left: -100 + }); + var group = new fabric.Group([ + new fabric.Rect({ strokeWidth: 0, width: 100, height: 100, fill: 'red' }), + new fabric.Rect({ strokeWidth: 0, width: 100, height: 100, fill: 'yellow', left: 100 }), + new fabric.Rect({ strokeWidth: 0, width: 100, height: 100, fill: 'blue', top: 100 }), + new fabric.Rect({ strokeWidth: 0, width: 100, height: 100, fill: 'green', left: 100, top: 100 }) + ], { strokeWidth: 0 }); + group.clipPath = clipPath; + canvas.add(group); + canvas.renderAll(); + callback(canvas.lowerCanvasEl); + } + + tests.push({ + test: 'Clip a group with a circle', + code: clipping2, + golden: 'clipping2.png', + percentage: 0.06, + }); + + // function clipping3(canvas, callback) { + // var clipPath = new fabric.Circle({ radius: 100, top: -100, left: -100 }); + // var small = new fabric.Circle({ radius: 50, top: -50, left: -50 }); + // var small2 = new fabric.Rect({ width: 30, height: 30, top: -50, left: -50 }); + // var group = new fabric.Group([ + // new fabric.Rect({ strokeWidth: 0, width: 100, height: 100, fill: 'red', clipPath: small }), + // new fabric.Rect({ strokeWidth: 0, width: 100, height: 100, fill: 'yellow', left: 100 }), + // new fabric.Rect({ strokeWidth: 0, width: 100, height: 100, fill: 'blue', top: 100, clipPath: small2 }), + // new fabric.Rect({ strokeWidth: 0, width: 100, height: 100, fill: 'green', left: 100, top: 100 }) + // ], { strokeWidth: 0 }); + // group.clipPath = clipPath; + // canvas.add(group); + // canvas.renderAll(); + // callback(canvas.lowerCanvasEl); + // } + + // FIX ON NODE + // tests.push({ + // test: 'Isolation of clipPath of group and inner objects', + // code: clipping3, + // golden: 'clipping3.png', + // percentage: 0.06, + // }); + + function clipping4(canvas, callback) { + var clipPath = new fabric.Circle({ radius: 20, strokeWidth: 0, top: -10, left: -10, scaleX: 2, skewY: 45 }); + var obj = new fabric.Rect({ top: 0, left: 0, strokeWidth: 0, width: 200, height: 200, fill: 'rgba(0,255,0,0.5)'}); + obj.fill = new fabric.Gradient({ + type: 'linear', + coords: { + x1: 0, + y1: 0, + x2: 200, + y2: 200, + }, + colorStops: [ + { + offset: 0, + color: 'red', + }, + { + offset: 1, + color: 'blue', + } + ] + }); + obj.clipPath = clipPath; + canvas.add(obj); + canvas.renderAll(); + callback(canvas.lowerCanvasEl); + } + + tests.push({ + test: 'ClipPath can be transformed', + code: clipping4, + golden: 'clipping4.png', + percentage: 0.06, + }); + + function clipping5(canvas, callback) { + var clipPath = new fabric.Circle({ radius: 20, strokeWidth: 0, top: -10, left: -10, scaleX: 2, skewY: 45 }); + var clipPath1 = new fabric.Circle({ radius: 15, rotate: 45, strokeWidth: 0, top: -100, left: -50, scaleX: 2, skewY: 45 }); + var clipPath2 = new fabric.Circle({ radius: 10, strokeWidth: 0, top: -20, left: -20, scaleY: 2, skewX: 45 }); + var group = new fabric.Group([clipPath, clipPath1, clipPath2]); + var obj = new fabric.Rect({ top: 0, left: 0, strokeWidth: 0, width: 200, height: 200, fill: 'rgba(0,255,0,0.5)'}); + obj.fill = new fabric.Gradient({ + type: 'linear', + coords: { + x1: 0, + y1: 0, + x2: 200, + y2: 200, + }, + colorStops: [ + { + offset: 0, + color: 'red', + }, + { + offset: 1, + color: 'blue', + } + ] + }); + obj.clipPath = group; + canvas.add(obj); + canvas.renderAll(); + callback(canvas.lowerCanvasEl); + } + + tests.push({ + test: 'ClipPath can be a group with many objects', + code: clipping5, + golden: 'clipping5.png', + percentage: 0.06, + }); + + function clipping6(canvas, callback) { + var clipPath = new fabric.Circle({ radius: 20, strokeWidth: 0, top: -10, left: -10, scaleX: 2, skewY: 45 }); + var clipPath1 = new fabric.Circle({ radius: 15, rotate: 45, strokeWidth: 0, top: -100, left: -50, scaleX: 2, skewY: 45 }); + var clipPath2 = new fabric.Circle({ radius: 10, strokeWidth: 0, top: -20, left: -20, scaleY: 2, skewX: 45 }); + var group = new fabric.Group([clipPath, clipPath1, clipPath2]); + var obj = new fabric.Rect({ top: 0, left: 0, strokeWidth: 0, width: 200, height: 200, fill: 'rgba(0,255,0,0.5)'}); + obj.fill = new fabric.Gradient({ + type: 'linear', + coords: { + x1: 0, + y1: 0, + x2: 200, + y2: 200, + }, + colorStops: [ + { + offset: 0, + color: 'red', + }, + { + offset: 1, + color: 'blue', + } + ] + }); + obj.clipPath = group; + group.inverted = true; + canvas.add(obj); + canvas.renderAll(); + callback(canvas.lowerCanvasEl); + } + + tests.push({ + test: 'ClipPath can be inverted, it will clip what is outside the clipPath', + code: clipping6, + golden: 'clipping6.png', + percentage: 0.06, + }); + + // function clipping7(canvas, callback) { + // var clipPath = new fabric.Circle({ radius: 30, strokeWidth: 0, top: -30, left: -30, skewY: 45 }); + // var obj1 = new fabric.Rect({ top: 0, left: 100, strokeWidth: 0, width: 100, height: 100, fill: 'rgba(0,255,0,0.8)'}); + // var obj2 = new fabric.Rect({ top: 0, left: 0, strokeWidth: 0, width: 100, height: 100, fill: 'rgba(255,255,0,0.8)'}); + // var obj3 = new fabric.Rect({ top: 100, left: 0, strokeWidth: 0, width: 100, height: 100, fill: 'rgba(0,255,255,0.8)'}); + // var obj4 = new fabric.Rect({ top: 100, left: 100, strokeWidth: 0, width: 100, height: 100, fill: 'rgba(255,0,0,0.8)'}); + // obj1.clipPath = clipPath; + // obj2.clipPath = clipPath; + // obj3.clipPath = clipPath; + // obj4.clipPath = clipPath; + // canvas.add(obj1); + // canvas.add(obj2); + // canvas.add(obj3); + // canvas.add(obj4); + // canvas.renderAll(); + // callback(canvas.lowerCanvasEl); + // } + + // FIX ON NODE + // tests.push({ + // test: 'Many Objects can share the same clipPath', + // code: clipping7, + // golden: 'clipping7.png', + // percentage: 0.06, + // }); + + function clipping8(canvas, callback) { + var clipPath = new fabric.Circle({ radius: 60, strokeWidth: 0, top: 40, left: 40, absolutePositioned: true }); + var obj1 = new fabric.Rect({ top: 0, left: 100, strokeWidth: 0, width: 100, height: 100, fill: 'rgba(0,255,0,0.8)'}); + var obj2 = new fabric.Rect({ top: 0, left: 0, strokeWidth: 0, width: 100, height: 100, fill: 'rgba(255,255,0,0.8)'}); + var obj3 = new fabric.Rect({ top: 100, left: 0, strokeWidth: 0, width: 100, height: 100, fill: 'rgba(0,255,255,0.8)'}); + var obj4 = new fabric.Rect({ top: 100, left: 100, strokeWidth: 0, width: 100, height: 100, fill: 'rgba(255,0,0,0.8)'}); + obj1.clipPath = clipPath; + obj2.clipPath = clipPath; + obj3.clipPath = clipPath; + canvas.add(obj1); + canvas.add(obj2); + canvas.add(obj3); + canvas.add(obj4); + canvas.renderAll(); + callback(canvas.lowerCanvasEl); + } + + tests.push({ + test: 'an absolute positioned clipPath, shared', + code: clipping8, + golden: 'clipping8.png', + percentage: 0.06, + }); + + function clipping9(canvas, callback) { + var clipPath = new fabric.Circle({ radius: 60, strokeWidth: 0, top: 10, left: 10 }); + var obj1 = new fabric.Rect({ top: 0, left: 100, strokeWidth: 0, width: 100, height: 100, fill: 'rgba(0,255,0,0.8)'}); + var obj2 = new fabric.Rect({ top: 0, left: 0, strokeWidth: 0, width: 100, height: 100, fill: 'rgba(255,255,0,0.8)'}); + var obj3 = new fabric.Rect({ top: 100, left: 0, strokeWidth: 0, width: 100, height: 100, fill: 'rgba(0,255,255,0.8)'}); + var obj4 = new fabric.Rect({ top: 100, left: 100, strokeWidth: 0, width: 100, height: 100, fill: 'rgba(255,0,0,0.8)'}); + canvas.add(obj1); + canvas.add(obj2); + canvas.add(obj3); + canvas.add(obj4); + canvas.clipPath = clipPath; + canvas.renderAll(); + callback(canvas.lowerCanvasEl); + } + + tests.push({ + test: 'a clipPath on the canvas', + code: clipping9, + golden: 'clipping9.png', + percentage: 0.06, + }); + + + tests.forEach(function(testObj) { + var testName = testObj.test; + var code = testObj.code; + var percentage = testObj.percentage; + var golden = testObj.golden; + var newModule = testObj.newModule; + if (newModule) { + QUnit.module(newModule, { + beforeEach: beforeEachHandler, + }); + } + QUnit.test(testName, function(assert) { + var done = assert.async(); + code(fabricCanvas, function(renderedCanvas) { + var width = renderedCanvas.width; + var height = renderedCanvas.height; + var totalPixels = width * height; + var imageDataCanvas = renderedCanvas.getContext('2d').getImageData(0, 0, width, height).data; + var canvas = fabric.document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + var ctx = canvas.getContext('2d'); + var output = ctx.getImageData(0, 0, width, height); + getImage(getGoldeName(golden), renderedCanvas, function(goldenImage) { + ctx.drawImage(goldenImage, 0, 0); + visualCallback.addArguments({ + enabled: true, + golden: canvas, + fabric: renderedCanvas, + diff: output + }); + var imageDataGolden = ctx.getImageData(0, 0, width, height).data; + var differentPixels = _pixelMatch(imageDataCanvas, imageDataGolden, output.data, width, height, pixelmatchOptions); + var percDiff = differentPixels / totalPixels * 100; + var okDiff = totalPixels * percentage; + var isOK = differentPixels < okDiff; + assert.ok( + isOK, + testName + ' has too many different pixels ' + differentPixels + '(' + okDiff + ') representing ' + percDiff + '%' + ); + if (!isOK) { + var stringa = imageDataToChalk(output); + console.log(stringa); + } + done(); + }); + }); + }); + }); +})(); diff --git a/test/visual/golden/clipping0.png b/test/visual/golden/clipping0.png new file mode 100644 index 0000000000000000000000000000000000000000..bcf570be1f9df95ea11b7732c7a7e2124db4d1f8 GIT binary patch literal 1347 zcmbtUeN<8h6vro1=-|{?9W8`sg5b%rv{Z&TG)=&-oHaAcN-?9gNIGhUrkKr2($_OF z%CAk7P^7pIKam?(^G&YO^*t1%etYhF~u`u%n02U8Ur zHjs3WJ1VWcvPHY(wNV-ksN=^DG?m_g)sVW$W~1C;2rL<1$u9@$dd_zZG5a0QC_=`n z6tn7nro`MuwCjJmHPw3|&L#1F!;J!q;>kAHQJZO-p5ntr+Tshaz&ic*T6I`Mk!@o7 zZMpVI5?l^!C(uMxCXnb@`lh3lHNmsCGEVx%OJ{_dnRh5MOZPiIqqBtk7?xvwO zVhg@3+>|=D8Fo!>4jsvFswLvggN#1q+8n0E#iGQQnN!4}Xw7hr^5v?1?zsES4a=6( zQe%totH&RXKx=}w(ND(S zO4TcKkLtRpL)zOZTFHFeOTSd!@v%1ly&j3UGRx9}0Mx2@8jYAiw+~ylIEm@#6(mL& z5B6?C`>2hySzlu6V&iGf{vJ4)_sqAT&B|co%O;H4>+ORfk7I4t3$MPyG$d(GkEfeT zBbT@}FnZuOcw@t8bKFsmd<&|gUP?|Gc|ieEcjS7$c~2Et$yw*Hn)CeS)LB~Ygll>P zc(di|!`I$upPf?Ak;kJ$t&}I4&woaNW2&O-jOdIr%bn)=-*hA8^>-0yX(96(3uY;N22MFdrfiUz2Ae0`knmBv(LgK z9RW|?gF$op?%M_#=el?)%0kLF`U0NO=;WZ8BjkcUEdlKZWpDFO6ZIZ@s7#~eNEQ`p zZFBAD8J5VhXwc-9>dy)fOf#^7S+wWbc#0&L1}M&5zI#6((QV8F6sjp+rG=a^j29d; z$=RxcxDw9U$+a+olyg8sazD9ohJ1(*8ukS(^hx-hb5=RN4sJtqkNl)@H@mtSp?-kk z7|hO@IYpAj={CVdNcp1zKv4>N@xq_8odV|TS8e8tJFRZLT+pDtC&8pAfJhA~L43jL z(ObuPlU9)?q{o76W5`6~EApWfB6n+cHEEKK?eK-JF1M0PhKSq%%b!MP`$`LTpnsO? zEOGsH3a_MOS2^I6l%a4Z5F(p)?3K$29Iz-VtrAO7Ps_lpX!$T?ytdALwFTgwuaH zY~`*Mfbb_lao88xp@1;E%Zj;`2%5WXnH)fN7g3?YO z$N{gce~&nCv+L`=yEq41`mu`v=NiD%PI1?lV1W*AD35HvcNVYok`lO}87Htye?%2q r{+-vC|DRfQ!1b4R*n!F*eaQ!h&7C_lT(sF`b1#b*oe-sp+*bMz>(WUM literal 0 HcmV?d00001 diff --git a/test/visual/golden/clipping01.png b/test/visual/golden/clipping01.png new file mode 100644 index 0000000000000000000000000000000000000000..af577e421db354c31bbad30d134c75473524a609 GIT binary patch literal 1938 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 literal 0 HcmV?d00001 diff --git a/test/visual/golden/clipping3.png b/test/visual/golden/clipping3.png new file mode 100644 index 0000000000000000000000000000000000000000..e62010e417f3ccdddb845bfee498750582b9435d GIT binary patch literal 5205 zcmX|Fby!nj`(D5ZnY4fm7$p)Cqb4~C36*k;ltv^SNFy*5r4$Ad5~3hAl#zn;knRxa zMx~`Y{O0$IKhAru^Pc-T@BQ58dG2$rH_AXy3rc&H761T1b+k2%$mfoK2Q`E|7MT(= z0RR@5j>avtztzUO&=L=0vv$>fN{TL{6135E$~UN@HToKnk8^LA2k53!nLHgp7m}{p zA0)qC%{)vTaeRfB5-NbSz*#E3UJjQPv5W*!&hzn6)$88MIX56in$%cgcGvwHBMvim z8#b+6H@}R`Y@VvP{_b%^x+?ci%sK`4nY(W8tCtK5Q)Vollk7;nD$2>O%q&6UZGon+{Q!*s-I964!Z?whP zZLye7@0!&HGH3kPZo}%B7b+Z_@(pJm9NOP>cu#mrcZdn_PEEhus;YcFfM?ORm2A-O zRBE2HCWMWb!r32E;jqW4YM_ShnGENiC;9lNse6i|Jat1xLtgSKIQK-Q^vQ zYJsdl*uTPAJpLhp=ocVAd+qpbMMZy?Kvk{0Cdq;j3+8xW|4^G|5FHKz9$h(7Ets29#Zp;rxpBB(ab zU^w|$CLwEb$5m`5M2mx{1VMW&9x-|@7r9$HgDK~<{4*g0{a`%dzV{vdN>Hz@27qM^ zx30GiLLGzqXA+Fo=~p@