diff --git a/examples/everything.amp.html b/examples/everything.amp.html index 18718d51ef01..54961addb487 100644 --- a/examples/everything.amp.html +++ b/examples/everything.amp.html @@ -64,11 +64,29 @@ display: block; margin: 16px; } + @font-face { + font-family: 'Comic AMP'; + font-style: bold; + font-weight: 700; + src: url(fonts/ComicAMP.ttf) format('truetype'); + } + .comic-amp-font-loaded .comic-amp { + font-family: 'Comic AMP', serif, sans-serif; + } + + .comic-amp-font-loading .comic-amp { + color: #0ff; + } + + .comic-amp-font-missing .comic-amp { + color: #f00; + } + @@ -79,13 +97,16 @@ - +

AMP #0

+

+ Quisque ultricies id augue a convallis. Vivamus euismod est quis tellus laoreet lacinia. In quam tellus, mollis nec porta eget, volutpat sit amet nibh. Duis ac odio sem. Sed consequat, ante gravida fringilla suscipit, libero libero ullamcorper metus, nec porta est elit at est. Curabitur vel diam ligula. Nulla bibendum malesuada odio. +

Lorem ipsum dolor sit amet, has nisl nihil convenire et, vim at aeque inermis reprehendunt. @@ -318,6 +339,15 @@

SVG

+ +
diff --git a/examples/font.amp.html b/examples/font.amp.html new file mode 100644 index 000000000000..78292b28df43 --- /dev/null +++ b/examples/font.amp.html @@ -0,0 +1,105 @@ + + + + + Font example + + + + + + + + + +

Lorem Ipsum

+ +

amp-font

+ +

+ "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit..." + "There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain..." +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur gravida ipsum vel hendrerit ultricies. Sed bibendum erat sit amet dui mattis imperdiet. Duis feugiat lobortis neque nec accumsan. Nam lacinia placerat enim in cursus. Sed id gravida arcu, sed condimentum mauris. Vestibulum convallis risus ut est ultrices mollis. Curabitur sit amet lorem et leo maximus consectetur non aliquet risus. Pellentesque tempus malesuada eros quis convallis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi dolor risus, convallis eget bibendum at, auctor vel enim. Phasellus posuere dictum fermentum. +

+

+ Quisque ultricies id augue a convallis. Vivamus euismod est quis tellus laoreet lacinia. In quam tellus, mollis nec porta eget, volutpat sit amet nibh. Duis ac odio sem. Sed consequat, ante gravida fringilla suscipit, libero libero ullamcorper metus, nec porta est elit at est. Curabitur vel diam ligula. Nulla bibendum malesuada odio. +

+

+ Pellentesque ultricies quam diam, sit amet sollicitudin ante imperdiet a. Pellentesque porta semper nisi, et lobortis est laoreet ac. Vivamus pulvinar egestas purus, vitae pharetra nisl mattis at. Duis gravida ac quam vel commodo. Pellentesque dignissim luctus magna, a fermentum ligula porttitor non. Duis sodales interdum urna, eu viverra elit dapibus vitae. Sed aliquet magna non erat suscipit, vel elementum nisl lacinia. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In eu tempor purus. Cras non libero vehicula, finibus arcu eget, eleifend nulla. Vestibulum non sollicitudin turpis. Fusce condimentum posuere risus nec congue. Curabitur aliquam lorem dolor. +

+

+ Sed mollis, justo ac volutpat gravida, nisl eros imperdiet massa, sed aliquam nulla odio in urna. Pellentesque eros urna, rutrum vel luctus id, ultrices a libero. Morbi ante justo, gravida et velit at, convallis tristique metus. Nam elementum luctus facilisis. Vestibulum nec augue dignissim, gravida odio nec, volutpat libero. Cras vitae sagittis tellus, sed tincidunt ipsum. Maecenas et mauris id nunc porttitor tempus sit amet at libero. +

+

+ Aenean ullamcorper risus quam, molestie sodales lacus volutpat at. Nunc vulputate est ut faucibus faucibus. Proin posuere viverra vestibulum. Vestibulum pretium nunc ut euismod sollicitudin. Etiam ornare posuere libero. Vestibulum urna massa, viverra sed ullamcorper ac, hendrerit sed dui. Vestibulum interdum lectus tellus, ut consequat quam pulvinar vitae. Mauris porttitor nulla porta urna convallis accumsan. Curabitur eget ante in libero fringilla elementum. Curabitur tempus arcu massa, gravida tristique erat convallis vitae. Sed lacinia elit justo, eget mollis dui vehicula sit amet. Nunc dignissim condimentum nunc, id pulvinar purus mattis ut. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque lacinia nibh ac enim laoreet imperdiet. +

+ + + + + + + diff --git a/examples/fonts/ComicAMP.ttf b/examples/fonts/ComicAMP.ttf new file mode 100644 index 000000000000..a9c900d92fcc Binary files /dev/null and b/examples/fonts/ComicAMP.ttf differ diff --git a/examples/fonts/ComicAMPBold.ttf b/examples/fonts/ComicAMPBold.ttf new file mode 100644 index 000000000000..6a92bc149d1e Binary files /dev/null and b/examples/fonts/ComicAMPBold.ttf differ diff --git a/extensions/amp-font/0.1/amp-font.js b/extensions/amp-font/0.1/amp-font.js new file mode 100644 index 000000000000..6c2d8a6bf8f4 --- /dev/null +++ b/extensions/amp-font/0.1/amp-font.js @@ -0,0 +1,197 @@ +/** + * Copyright 2015 The AMP HTML Authors. All Rights Reserved. + * + * 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. + */ + + +/** + * @fileoverview Triggers and monitors loading of custom fonts on AMP pages. + * Example: + * + * + * + * + * + * the amp-font element's layout type is nodisplay. + */ + +import {FontLoader} from './fontloader'; +import {timer} from '../../../src/timer'; +import * as style from '../../../src/style'; + +/** @private @const {number} */ +const DEFAULT_TIMEOUT_ = 3000; + +/** @private @const {string} */ +const DEFAULT_WEIGHT_ = '400'; + +/** @private @const {string} */ +const DEFAULT_VARIANT_ = 'normal'; + +/** @private @const {string} */ +const DEFAULT_STYLE_ = 'normal'; + +/** @private @const {string} */ +const DEFAULT_SIZE_ = 'medium'; + +/** @private @const {number}*/ +/** + * https://output.jsbin.com/badore - is js bin experiment to test timeouts on + * various mobile devices. Loade the page and try refreshing it to serve the + * font from cache. + * + * Font load times (from the browser cache) documented as follows: + * Wifi + * iPhone6(iOs 9.1) - safari - 2ms ~ 14ms + * Windows phone(8.1 Update 2) - IE - 20ms ~ 46ms + * Nexus5 (Android 6.0.0) - Chrome(44.0.2403.133) - 24ms ~ 52ms + * Samsung Galaxy S4 (Android 4.4.2) - Default Browser - 7ms - 24ms + * + * LTE + * iPhone6(iOs 9.1) - safari - 6ms ~ 14ms + * Nexus5 (Android 6.0.0) - Chrome(46.0.2) - 46ms ~ 100ms + */ +const CACHED_FONT_LOAD_TIME_ = 100; + + +export class AmpFont extends AMP.BaseElement { + + + /** @override */ + prerenderAllowed() { + return true; + } + + + /** @override */ + buildCallback() { + /** @private @const {string} */ + this.fontFamily_ = AMP.assert(this.element.getAttribute('font-family'), + 'The font-family attribute is required for %s', + this.element); + /** @private @const {string} */ + this.fontWeight_ = + this.element.getAttribute('font-weight') || DEFAULT_WEIGHT_; + /** @private @const {string} */ + this.fontStyle_ = + this.element.getAttribute('font-style') || DEFAULT_STYLE_; + /** @private @const {string} */ + this.fontVariant_ = + this.element.getAttribute('font-variant') || DEFAULT_VARIANT_; + /** @private @const {!Document} */ + this.document_ = this.getWin().document; + /** @private @const {!Element} */ + this.documentElement_ = this.document_.documentElement; + /** @private @const {!FontLoader} */ + this.fontLoader_ = new FontLoader(this.getWin()); + this.startLoad_(); + } + + + /** + * Starts to download the font. + * @private + */ + startLoad_() { + /** @type FontConfig */ + const fontConfig = { + style: this.fontStyle_, + variant: this.fontVariant_, + weight: this.fontWeight_, + size: DEFAULT_SIZE_, + family: this.fontFamily_ + }; + this.fontLoader_.load(fontConfig, this.getTimeout_()).then(() => { + this.onFontLoadSuccess_(); + }).catch(error => { + this.onFontLoadError_(); + throw error; + }); + } + + + /** + * @private + */ + onFontLoadSuccess_() { + const addClassName = this.element.getAttribute('on-load-add-class'); + const removeClassName = + this.element.getAttribute('on-load-remove-class'); + this.onFontLoadFinish_(addClassName, removeClassName); + } + + + /** + * @private + */ + onFontLoadError_() { + const addClassName = this.element.getAttribute('on-error-add-class'); + const removeClassName = + this.element.getAttribute('on-error-remove-class'); + this.onFontLoadFinish_(addClassName, removeClassName); + } + + + /** + * @param {?string} addClassName css class to be added to the + * document-element. + * @param {?string} removeClassName css class to be removed from the + * document-element. + * @private + */ + onFontLoadFinish_(addClassName, removeClassName) { + if (addClassName) { + this.documentElement_.classList.add(addClassName); + } + if (removeClassName) { + this.documentElement_.classList.remove(removeClassName); + this.document_.body.classList.remove(removeClassName); + }; + this.dispose_(); + } + + + /** + * @private + */ + dispose_() { + this.fontLoader_ = null; + } + + + /** + * Computes and returns the time (in ms) to wait for font download. + * @returns {number} time (in ms) to wait for font download. + * @private + */ + getTimeout_() { + let timeoutInMs = parseInt(this.element.getAttribute('timeout'), 10); + timeoutInMs = isNaN(timeoutInMs) || timeoutInMs < 0 ? + DEFAULT_TIMEOUT_ : timeoutInMs; + timeoutInMs = Math.max( + (timeoutInMs - timer.timeSinceStart()), CACHED_FONT_LOAD_TIME_); + return timeoutInMs; + } +} + + +AMP.registerElement('amp-font', AmpFont); diff --git a/extensions/amp-font/0.1/fontloader.js b/extensions/amp-font/0.1/fontloader.js new file mode 100644 index 000000000000..265e41528043 --- /dev/null +++ b/extensions/amp-font/0.1/fontloader.js @@ -0,0 +1,258 @@ +/** + * Copyright 2015 The AMP HTML Authors. All Rights Reserved. + * + * 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. + */ + +/** + * @typedef {{ + * style: string, + * variant: string, + * weight: string, + * size: string, + * family: string + * }} + */ +let FontConfig; + + +/** @private @const {Array.} */ +const DEFAULT_FONTS_ = ['sans-serif', 'serif']; + +/** @private @const {string} */ +const TEST_STRING_ = 'MAxmTYklsjo190QW'; + +/** @private @const {number} */ +const TOLERANCE_ = 2; + + +import {removeElement} from '../../../src/dom'; +import {timer} from '../../../src/timer'; +import {vsyncFor} from '../../../src/vsync'; +import * as style from '../../../src/style'; + + +export class FontLoader { + + /** + * @param {!Window} win + */ + constructor(win) { + /** @private @const {!Window} */ + this.win_ = win; + /** @private @const {!Document} */ + this.document_ = win.document; + /** @private {?Element} */ + this.container_ = null; + /** @private {?Array.} */ + this.defaultFontElements_ = null; + /** @private {?Element} */ + this.customFontElement_ = null; + /** @private {boolean} */ + this.fontLoadResolved_ = false; + /** @private {boolean} */ + this.fontLoadRejected_ = false; + /** @private {FontConfig} */ + this.fontConfig_ = null; + } + + + /** + * Triggers the font load. Returns promise that will complete when loading + * is considered to be complete. + * @param {!FontConfig} fontConfig Config that describes the font to be + * loaded. + * @param {number} timeout number of milliseconds after which the font load + * attempt would be stopped. + * @return {!Promise} + */ + load(fontConfig, timeout) { + this.fontConfig_ = fontConfig; + return timer.timeoutPromise(timeout, this.load_()).then(() => { + this.fontLoadResolved_ = true; + this.dispose_(); + }).catch(reason => { + this.fontLoadRejected_ = true; + this.dispose_(); + throw reason; + }); + } + + + /** + * Triggers the font load. Returns promise that will complete when loading + * is considered to be complete. + * @return {!Promise} + * @private + */ + load_() { + return new Promise((resolve, reject) => { + /* style | variant | weight | size/line-height | family */ + /* font: italic small-caps bolder 16px/3 cursive; */ + const fontString = ( + this.fontConfig_.style + ' ' + + this.fontConfig_.variant + ' ' + + this.fontConfig_.weight + ' ' + + this.fontConfig_.size + ' ' + + this.fontConfig_.family); + + if (this.canUseNativeApis_()) { + // Check if font already exists. + if (this.document_.fonts.check(fontString)) { + resolve(); + } else { + // Load font with native api if supported. + this.document_.fonts.load(fontString).then(() => { + if (this.document_.fonts.check(fontString)) { + resolve(); + } else { + reject(new Error('Font could not be loaded,' + + ' probably due to incorrect @font-face.')); + } + }).catch(reject); + } + } else { + // Load font with polyfill if native api is not supported. + this.loadWithPolyfill_().then(resolve, reject); + } + }); + } + + + /** + * @returns {boolean} True when native font api is supported by the browser. + * @private + */ + canUseNativeApis_() { + return 'fonts' in this.document_ && false; + } + + + /** + * Make the browsers that don't support font loading events to download the + * custom font by creating an element (with text) not visible on the viewport. + * Font download is detected by comparing the elements height and width with + * measurements between default fonts and custom font. + * @private + */ + loadWithPolyfill_() { + return new Promise((resolve, reject) => { + const vsync = vsyncFor(this.win_); + // Create DOM elements + this.createElements_(); + // Measure until timeout (or font load). + const vsyncTask = { + measure: () => { + if (this.fontLoadResolved_) { + resolve(); + } else if (this.fontLoadRejected_) { + reject(new Error('Font loading timed out.')); + } else if (this.compareMeasurements_()) { + resolve(); + } else { + vsync.run(vsyncTask); + } + }, + mutate: () => {} + }; + // TODO(dvoytenko): Fix https://github.com/ampproject/amphtml/issues/839. + vsync.run(vsyncTask); + }); + } + + + /** + * Step 1 for loading font on browsers that don't support font loading events. + * Creates divs hidden from the viewport and measures dimensions for default + * fonts. + * @private + */ + createElements_() { + const containerElement = this.container_ = + this.document_.createElement('div'); + style.setStyles(containerElement, { + fontSize: this.fontConfig_.size, + fontVariant: this.fontConfig_.variant, + left: '-999px', + lineHeight: 'normal', + margin: 0, + padding: 0, + position: 'absolute', + top: '-999px', + visibility: 'hidden' + }); + this.defaultFontElements_ = []; + DEFAULT_FONTS_.forEach(font => { + const defaultFontElement = this.document_.createElement('div'); + this.defaultFontElements_.push(defaultFontElement); + defaultFontElement.textContent = TEST_STRING_; + style.setStyles(defaultFontElement, { + fontFamily: font, + margin: 0, + padding: 0, + whiteSpace: 'nowrap' + }); + containerElement.appendChild(defaultFontElement); + }); + // Adding custom font family to the element to trigger load. + // The loading will begin after the container has been appended to the body. + const customFontElement = this.customFontElement_ = + this.document_.createElement('div'); + style.setStyles(customFontElement, { + fontFamily: this.fontConfig_.family, + margin: 0, + padding: 0, + whiteSpace: 'nowrap' + }); + customFontElement.textContent = TEST_STRING_; + containerElement.appendChild(customFontElement); + this.document_.body.appendChild(containerElement); + } + + + /** + * Compare dimensions between elements styled with default fonts and custom + * font. + * @returns {boolean} Returns true if the dimensions are noticeably different + * else returns false. + * @private + */ + compareMeasurements_() { + return this.defaultFontElements_.some(defaultElement => { + const hasWidthChanged = ( + Math.abs( + defaultElement./*OK*/offsetWidth - + this.customFontElement_./*OK*/offsetWidth) > + TOLERANCE_); + const hasHeightChanged = ( + Math.abs( + defaultElement./*OK*/offsetHeight - + this.customFontElement_./*OK*/offsetHeight) > + TOLERANCE_); + return (hasWidthChanged || hasHeightChanged); + }); + } + + + /** + * @private + */ + dispose_() { + if (this.container_) { + removeElement(this.container_); + } + this.container_ = null; + this.defaultFontElements_ = null; + this.customFontElement_ = null; + } +} diff --git a/extensions/amp-font/0.1/test/test-amp-font.js b/extensions/amp-font/0.1/test/test-amp-font.js new file mode 100644 index 000000000000..1262bed5d156 --- /dev/null +++ b/extensions/amp-font/0.1/test/test-amp-font.js @@ -0,0 +1,89 @@ +/** + * Copyright 2015 The AMP HTML Authors. All Rights Reserved. + * + * 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. + */ + +import {AmpFont} from '../amp-font'; +import {FontLoader} from '../fontloader'; +import {adopt} from '../../../../src/runtime'; +import {createIframePromise} from '../../../../testing/iframe'; +import * as sinon from 'sinon'; + +adopt(window); + +describe('amp-font', function() { + + let sandbox; + let setupLoadSuccessSpy; + let setupLoadFailureSpy; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + }); + + afterEach(() => { + restoreSpy(setupLoadSuccessSpy); + restoreSpy(setupLoadFailureSpy); + restoreSpy(sandbox); + }); + + function getAmpFontIframe() { + return createIframePromise().then(iframe => { + iframe.doc.body.classList.add('comic-amp-font-loading'); + const font = iframe.doc.createElement('amp-font'); + font.setAttribute('layout', 'nodisplay'); + font.setAttribute('font-family', 'Comic AMP'); + font.setAttribute('timeout', '1000'); + font.setAttribute('while-loading-class', ''); + font.setAttribute('on-error-add-class', 'comic-amp-font-missing'); + font.setAttribute('on-load-add-class', 'comic-amp-font-loaded'); + font.setAttribute('on-error-remove-class', 'comic-amp-font-loading'); + font.setAttribute('on-load-remove-class', 'comic-amp-font-loading'); + return iframe.addElement(font).then(f => { + return Promise.resolve(iframe); + }); + }); + } + + function restoreSpy(spy) { + if (spy) { + spy.restore(); + } + } + + it('should timeout while loading custom font', function(done) { + setupLoadFailureSpy = sinon.stub(FontLoader.prototype, 'load') + .returns(Promise.reject('mock rejection')); + return getAmpFontIframe().then(iframe => { + expect(iframe.doc.documentElement) + .to.have.class('comic-amp-font-missing'); + expect(iframe.doc.body) + .to.not.have.class('comic-amp-font-loading'); + done(); + }); + }); + + it('should load custom font', function(done) { + setupLoadSuccessSpy = + sinon.stub(FontLoader.prototype, 'load').returns(Promise.resolve()); + return getAmpFontIframe().then(iframe => { + expect(iframe.doc.documentElement) + .to.have.class('comic-amp-font-loaded'); + expect(iframe.doc.body) + .to.not.have.class('comic-amp-font-loading'); + done(); + }); + }); + +}); diff --git a/extensions/amp-font/0.1/test/test-fontloader.js b/extensions/amp-font/0.1/test/test-fontloader.js new file mode 100644 index 000000000000..b294771226c5 --- /dev/null +++ b/extensions/amp-font/0.1/test/test-fontloader.js @@ -0,0 +1,222 @@ +/** + * Copyright 2015 The AMP HTML Authors. All Rights Reserved. + * + * 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. + */ + +import {FontLoader} from '../fontloader'; +import {adopt} from '../../../../src/runtime'; +import {createIframePromise} from '../../../../testing/iframe'; +import * as sinon from 'sinon'; + +adopt(window); + +/** @private @const {string} */ +const FONT_FACE_ = "\ + @font-face {\ + font-family: 'Comic AMP';\ + src: url(/base/examples/fonts/ComicAMP.ttf) format('truetype');\ + }\ +"; + +const CSS_RULES_ = "\ + .comic-amp-font-loaded {\ + font-family: 'Comic AMP', serif, sans-serif;\ + color: #0f0;\ + }\ +"; + +/** @private @const {!FontConfig} */ +const FONT_CONFIG = { + style: 'normal', + variant: 'normal', + weight: '400', + size: 'medium', + family: 'Comic AMP' +}; + + +/** @private @const {!FontConfig} */ +const FAILURE_FONT_CONFIG = { + style: 'normal', + variant: 'normal', + weight: '400', + size: 'medium', + family: 'Comic BLAH' +}; + +describe('FontLoader', () => { + + let sandbox; + let clock; + let fontloader; + let setupFontCheckSpy; + let setupFontLoadSpy; + let setupCanUseNativeApisSpy; + let setupLoadWithPolyfillSpy; + let setupDisposeSpy; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + clock = sinon.useFakeTimers(); + setupLoadWithPolyfillSpy = + sinon.spy(FontLoader.prototype, 'loadWithPolyfill_'); + setupCreateElementsSpy = + sinon.spy(FontLoader.prototype, 'createElements_'); + setupDisposeSpy = sinon.spy(FontLoader.prototype, 'dispose_'); + }); + + afterEach(() => { + restoreSpy(setupDisposeSpy); + restoreSpy(setupCreateElementsSpy); + restoreSpy(setupLoadWithPolyfillSpy); + restoreSpy(setupCanUseNativeApisSpy); + restoreSpy(setupFontCheckSpy); + restoreSpy(setupFontLoadSpy); + restoreSpy(clock); + restoreSpy(sandbox); + fontloader = null; + }); + + function restoreSpy(spy) { + if (spy) { + spy.restore(); + } + } + + function getIframe() { + return createIframePromise().then(iframe => { + const style = iframe.doc.createElement('style'); + style.textContent = FONT_FACE_ + CSS_RULES_; + iframe.doc.head.appendChild(style); + const textEl = iframe.doc.createElement('p'); + textEl.textContent = + "Neque porro quisquam est qui dolorem ipsum quia dolor"; + iframe.doc.body.appendChild(textEl); + setupFontCheckSpy = sinon.spy(iframe.doc.fonts, 'check'); + setupFontLoadSpy = sinon.spy(iframe.doc.fonts, 'load'); + fontloader = new FontLoader(iframe.win); + return Promise.resolve(iframe); + }); + } + + it('should check and load font via native api', () => { + return getIframe().then(iframe => { + fontloader.load(FONT_CONFIG, 3000).then(() => { + iframe.doc.documentElement.classList.add('comic-amp-font-loaded'); + expect(setupFontCheckSpy.callCount).to.equal(1); + expect(setupFontLoadSpy.callCount).to.equal(1); + expect(setupDisposeSpy.callCount).to.equal(1); + }).catch(() => { + assert.fail('Font load failed'); + }); + }); + }); + + it('should check and load font via polyfill', () => { + return getIframe().then(iframe => { + setupCanUseNativeApisSpy = + sinon.stub(FontLoader.prototype, 'canUseNativeApis_').returns(false); + fontloader.load(FONT_CONFIG, 3000).then(() => { + iframe.doc.documentElement.classList.add('comic-amp-font-loaded'); + expect(setupFontCheckSpy.callCount).to.equal(0); + expect(setupFontLoadSpy.callCount).to.equal(0); + expect(setupLoadWithPolyfillSpy.callCount).to.equal(1); + expect(setupCreateElementsSpy.callCount).to.equal(1); + expect(setupDisposeSpy.callCount).to.equal(1); + }).catch(() => { + assert.fail('Font load failed'); + }); + }); + }); + + it('should error when font is not available', () => { + return getIframe().then(iframe => { + fontloader.load(FAILURE_FONT_CONFIG, 3000).then(() => { + assert.fail('Font loaded when it should have failed.'); + }).catch(() => { + expect(setupFontCheckSpy.callCount).to.equal(1); + expect(setupFontLoadSpy.callCount).to.equal(1); + expect(setupDisposeSpy.callCount).to.equal(1); + }); + }); + }); + + it('should error when font is not available via polyfill', () => { + return getIframe().then(iframe => { + setupCanUseNativeApisSpy = + sinon.stub(FontLoader.prototype, 'canUseNativeApis_').returns(false); + fontloader.load(FONT_CONFIG, 3000).then(() => { + iframe.doc.documentElement.classList.add('comic-amp-font-loaded'); + assert.fail('Font loaded when it should have failed.'); + }).catch(() => { + expect(setupFontCheckSpy.callCount).to.equal(0); + expect(setupFontLoadSpy.callCount).to.equal(0); + expect(setupLoadWithPolyfillSpy.callCount).to.equal(1); + expect(setupCreateElementsSpy.callCount).to.equal(1); + expect(setupDisposeSpy.callCount).to.equal(1); + }); + }); + }); + + it('should check if elements are being created when using polyfill', () => { + return getIframe().then(iframe => { + setupCanUseNativeApisSpy = + sinon.stub(FontLoader.prototype, 'canUseNativeApis_').returns(false); + setupDisposeSpy.restore(); + setupDisposeSpy = + sinon.stub(FontLoader.prototype, 'dispose_').returns(undefined); + const initialElementsCount = iframe.doc.getElementsByTagName('*').length; + fontloader.load(FONT_CONFIG, 3000).then(() => { + iframe.doc.documentElement.classList.add('comic-amp-font-loaded'); + const finalElementsCount = iframe.doc.getElementsByTagName('*').length; + expect(initialElementsCount).to.be.below(finalElementsCount); + }).catch(() => { + assert.fail('Font load failed'); + }); + }); + }); + + it('should check if elements created using the polyfill are disposed', () => { + return getIframe().then(iframe => { + setupCanUseNativeApisSpy = + sinon.stub(FontLoader.prototype, 'canUseNativeApis_').returns(false); + const initialElementsCount = iframe.doc.getElementsByTagName('*').length; + fontloader.load(FONT_CONFIG, 3000).then(() => { + iframe.doc.documentElement.classList.add('comic-amp-font-loaded'); + const finalElementsCount = iframe.doc.getElementsByTagName('*').length; + expect(initialElementsCount).to.equal(finalElementsCount); + }).catch(() => { + assert.fail('Font load failed'); + }); + }); + }); + + it('should check compare elements', () => { + return getIframe().then(iframe => { + const defaultDiv = iframe.doc.createElement('div'); + defaultDiv.style.fontFamily = 'serif'; + defaultDiv.style.position = 'absolute'; + defaultDiv.textContent = "HelloWSSIENd2939Qq"; + const fontDiv = iframe.doc.createElement('div'); + fontDiv.style.fontFamily = 'Comic AMP'; + fontDiv.style.position = 'absolute'; + fontDiv.textContent = "HelloWSSIENd2939Qq"; + iframe.doc.body.appendChild(defaultDiv); + iframe.doc.body.appendChild(fontDiv); + fontloader.defaultFontElements_ = [defaultDiv]; + fontloader.customFontElement_ = fontDiv; + expect(fontloader.compareMeasurements_()).to.be.true; + }); + }); +}); diff --git a/extensions/amp-font/amp-font.md b/extensions/amp-font/amp-font.md new file mode 100644 index 000000000000..2d9c6ba9f967 --- /dev/null +++ b/extensions/amp-font/amp-font.md @@ -0,0 +1,83 @@ + + +### `amp-font` + +The `amp-font` extension can trigger and monitor the loading of custom fonts on AMP pages. + + +####Behavior + +The `amp-font` extension should be used for controlling timeouts on font loading. + +The `amp-font` extension allows adding and removing CSS classes from document.documentElement based on whether a font was loaded or is in error-state. + +Example: +```html + + +``` + +The extension observes loading of a font and when it loads executes the optional attributes `on-load-add-class` and `on-load-remove-class` and when there is any error or timeout runs `on-error-remove-class` and `on-error-add-class`. + +Using these classes authors can guard whether a font is displayed and get the following results: + +- get a short (e.g. 3000ms) timeout in Safari similar to other browsers +- implement FOIT where the page renders with no text before the font comes in +- make the timeout very short and only use a font if it was already cached. + + +The `amp-font` extension accepts the `layout` value: `nodisplay` + +####Attributes + +**font-family** + +The font-family of the custom font being loaded. + +**timeout** + +Time in milliseconds after which the we don't wait for the custom font to be available. This attribute is optional and it's default value is 3000. If the timeout is set to 0 then the amp-font loads the font if it is already in the cache, otherwise the font would not be loaded. If the timeout is has an invalid value then the timeout defaults to 3000. + +**on-load-add-class** + +CSS class that would be added to the `document.documentElement` after making sure that the custom font is available for display. This attribute is optional. + +**on-load-remove-class** + +CSS class that would be removed from the `document.documentElement` and `document.body` after making sure that the custom font is available for display. This attribute is optional. + +**on-error-add-class** + +CSS class that would be added to the `document.documentElement`, if the timeout interval runs out before the font becomes available for use. This attribute is optional. + +**on-error-remove-class** + +CSS class that would be removed from the `document.documentElement` and `document.body` , if the timeout interval runs out before the font becomes available for use. This attribute is optional. + +**font-weight, font-style, font-variant** + +The attributes above should all behave like they do on standard iframes. \ No newline at end of file diff --git a/gulpfile.js b/gulpfile.js index 04244603af51..645c1617a7d3 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -68,6 +68,7 @@ function buildExtensions(options) { buildExtension('amp-audio', '0.1', false, options); buildExtension('amp-carousel', '0.1', true, options); buildExtension('amp-fit-text', '0.1', true, options); + buildExtension('amp-font', '0.1', false, options); buildExtension('amp-iframe', '0.1', false, options); buildExtension('amp-image-lightbox', '0.1', true, options); buildExtension('amp-instagram', '0.1', false, options); @@ -280,6 +281,8 @@ function buildExamples(watch) { copyHandler.bind(null, 'examples/img folder')); fs.copy('examples/video/', 'examples.build/video/', {clobber: true}, copyHandler.bind(null, 'examples/video folder')); + fs.copy('examples/fonts/', 'examples.build/fonts/', {clobber: true}, + copyHandler.bind(null, 'examples/fonts folder')); // Also update test-example-validation.js buildExample('ads.amp.html'); @@ -293,6 +296,7 @@ function buildExamples(watch) { buildExample('metadata-examples/video-json-ld.amp.html'); buildExample('metadata-examples/video-microdata.amp.html'); buildExample('everything.amp.html'); + buildExample('font.amp.html'); buildExample('instagram.amp.html'); buildExample('released.amp.html'); buildExample('twitter.amp.html'); diff --git a/src/timer.js b/src/timer.js index c2ff7da56acc..9b9afc5fa778 100644 --- a/src/timer.js +++ b/src/timer.js @@ -32,6 +32,9 @@ export class Timer { this.taskCount_ = 0; this.canceled_ = {}; + + /** @const {number} */ + this.startTime_ = this.now(); } /** @@ -43,6 +46,14 @@ export class Timer { return Number(new Date()); } + /** + * Returns time since start in milliseconds. + * @return {number} + */ + timeSinceStart() { + return this.now() - this.startTime_; + } + /** * Runs the provided callback after the specified delay. This uses a micro * task for 0 or no specified time. This means that the delay will actually