diff --git a/src/map.js b/src/map.js index 69c40a1225..67b2115a16 100644 --- a/src/map.js +++ b/src/map.js @@ -1711,7 +1711,14 @@ var map = function (arg) { defer = defer.then(function () { var canvas = result; if (type !== 'canvas') { - result = result.toDataURL(type, encoderOptions); + try { + result = result.toDataURL(type, encoderOptions); + } catch (err) { + console.warn('Failed to convert screenshot to output', err); + var failure = $.Deferred(); + failure.reject(); + return failure; + } } m_this.geoTrigger(geo_event.screenshot.ready, { canvas: canvas, diff --git a/src/util/index.js b/src/util/index.js index b4ef70cd1f..30ebfb8d29 100644 --- a/src/util/index.js +++ b/src/util/index.js @@ -8,10 +8,16 @@ var ClusterGroup = require('./clustering.js'); var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +var svgForeignObject = '' + + '' + + '' + + ''; + var m_timingData = {}, m_timingKeepRecent = 200, m_threshold = 15, - m_originalRequestAnimationFrame; + m_originalRequestAnimationFrame, + m_htmlToImageSupport; /** * @typedef {object} geo.util.cssColorConversionRecord @@ -1060,6 +1066,12 @@ var util = module.exports = { var deferList = [], results = []; + /* Remove comments to avoid dereferencing commented out sections. + * To match across lines, use [^\0] rather than . */ + css = css.replace(/\/\*[^\0]*?\*\//g, ''); + /* reduce whitespace to make the css shorter */ + css = css.replace(/\r/g, '\n').replace(/\s+\n/g, '\n') + .replace(/\n\s+/g, '\n').replace(/\n\n+/g, '\n'); if (baseUrl) { var match = /(^[^?#]*)\/[^?#/]*([?#]|$)/g.exec(baseUrl); baseUrl = match && match[1] ? match[1] + '/' : null; @@ -1109,6 +1121,49 @@ var util = module.exports = { }); }, + /** + * Check if the current browser supports covnerting html to an image via an + * svg foreignObject and canvas. If this has not been checked before, it + * returns a Deferred that resolves to a boolean (never rejects). If the + * check has been done before, it returns a boolean. + * + * @returns {boolean|jQuery.Deferred} + */ + htmlToImageSupported: function () { + if (m_htmlToImageSupport === undefined) { + var defer = $.Deferred(); + var svg = $(svgForeignObject); + svg.attr({ + width: '10px', + height: '10px', + 'text-rendering': 'optimizeLegibility' + }); + $('foreignObject', svg).append('
'); + var img = new Image(); + img.onload = img.onerror = function () { + var canvas = document.createElement('canvas'); + canvas.width = 10; + canvas.height = 10; + var context = canvas.getContext('2d'); + context.drawImage(img, 0, 0); + try { + canvas.toDataURL(); + m_htmlToImageSupport = true; + } catch (err) { + console.warn( + 'This browser does not support converting HTML to an image via ' + + 'SVG foreignObject. Some functionality will be limited.', err); + m_htmlToImageSupport = false; + } + defer.resolve(m_htmlToImageSupport); + }; + img.src = 'data:image/svg+xml;base64,' + btoa(util.escapeUnicodeHTML( + new XMLSerializer().serializeToString(svg[0]))); + return defer; + } + return m_htmlToImageSupport; + }, + /** * Convert an html element to an image. This attempts to localize any * images within the element. If there are other external references, the @@ -1124,7 +1179,9 @@ var util = module.exports = { * @memberof geo.util */ htmlToImage: function (elem, parents) { - var defer = $.Deferred(), container, deferList = []; + var defer = $.Deferred(), + deferList = [util.htmlToImageSupported()], + container; var parent = $(elem); elem = $(elem).clone(); @@ -1191,6 +1248,10 @@ var util = module.exports = { var href = $(this).attr('href'); $.get(href).done(function (css) { util.dereferenceCssUrls(css, styleElem, styleDefer, undefined, href); + }).fail(function (xhr, status, err) { + console.warn('Failed to dereference ' + href, status, err); + styleElem.remove(); + styleDefer.resolve(); }); } deferList.push(styleDefer); @@ -1198,26 +1259,32 @@ var util = module.exports = { }); $.when.apply($, deferList).then(function () { - var svg = $('' + - '' + - ''); + var svg = $(svgForeignObject); svg.attr({ width: parent.width() + 'px', height: parent.height() + 'px', + // Adding this via the attr call works in Firefox headless, whereas if + // it is part of the svgForeignObject string, it does not. 'text-rendering': 'optimizeLegibility' }); $('foreignObject', svg).append(container); var img = new Image(); - img.onload = function () { + if (!util.htmlToImageSupported()) { defer.resolve(img); - }; - img.onerror = function () { - defer.reject(); - }; - img.src = 'data:image/svg+xml;base64,' + - btoa(util.escapeUnicodeHTML( - new XMLSerializer().serializeToString(svg[0]))); + } else { + img.onload = function () { + defer.resolve(img); + }; + img.onerror = function () { + console.warn('Failed to render html to image'); + defer.reject(); + }; + // Firefox requires the HTML to be base64 encoded. Chrome doesn't, but + // doing so does no harm. + img.src = 'data:image/svg+xml;base64,' + btoa(util.escapeUnicodeHTML( + new XMLSerializer().serializeToString(svg[0]))); + } }); return defer; }, diff --git a/tests/cases/map.js b/tests/cases/map.js index 898dbbaddf..9a0bdf8b79 100644 --- a/tests/cases/map.js +++ b/tests/cases/map.js @@ -822,8 +822,9 @@ describe('geo.core.map', function () { done(); }); }); + // These tests won't work in PhantomJS. See + // https://bugs.webkit.org/show_bug.cgi?id=17352, also 29305 and 129172. if (!isPhantomJS()) { - // this test won't work in PhantomJS. it('layer background', function (done) { var layer3 = m.createLayer('ui'); layer3.node().css('background-image', 'url(/data/tilefancy.png)'); @@ -834,9 +835,6 @@ describe('geo.core.map', function () { done(); }); }, 10000); - } - if (!isPhantomJS()) { - // this test won't work in PhantomJS. it('layer css background', function (done) { geo.jQuery('head').append(''); var layer3 = m.createLayer('ui'); @@ -849,7 +847,49 @@ describe('geo.core.map', function () { done(); }); }, 10000); + it('layer missing css background', function (done) { + geo.jQuery('head').append(''); + var layer3 = m.createLayer('ui'); + layer3.node().addClass('image-background'); + layer3.opacity(0.5); + m.screenshot().then(function (result) { + expect(result).not.toEqual(ss.basic); + expect(result).not.toEqual(ss.nobackground); + m.deleteLayer(layer3); + done(); + }); + }, 10000); + } + // end of non-PhantomJS tests + if (isPhantomJS()) { + it('no html to image warning', function (done) { + var layer3 = m.createLayer('ui'); + layer3.node().css('background-image', 'url(/data/tilefancy.png)'); + var warn = sinon.stub(console, 'warn', function () {}); + m.screenshot().then(function (result) { + expect(warn.calledOnce).toBe(true); + expect(result).toEqual(ss.basic); + m.deleteLayer(layer3); + console.warn.restore(); + done(); + }); + }, 10000); + it('warnings on html to image failures', function (done) { + var layer3 = m.createLayer('ui'); + layer3.node().css('background-image', 'url(/data/tilefancy.png)'); + var warn = sinon.stub(console, 'warn', function () {}); + sinon.stub(geo.util, 'htmlToImageSupported', function () { return true; }); + m.screenshot().fail(function () { + expect(warn.calledOnce).toBe(true); + expect(warn.calledWith('Failed to convert screenshot to output')).toBe(true); + m.deleteLayer(layer3); + geo.util.htmlToImageSupported.restore(); + console.warn.restore(); + done(); + }); + }, 10000); } + // end of PhantomJS tests it('layers in a different order', function (done) { m.screenshot([layer2, layer1]).then(function (result) { // the order doesn't matter