Typescript conversion (#1828)

* initial typescript conversion

* test: update overflow+transform ref test

* fix: correctly render pseudo element content

* fix: testrunner build

* fix: karma test urls

* test: update underline tests with <u> elements

* test: update to es6-promise polyfill

* test: remove watch from server

* test: remove flow

* format: update prettier for typescript

* test: update eslint to use typescript parser

* test: update linear gradient reftest

* test: update test runner

* test: update testrunner promise polyfill

* fix: handle display: -webkit-flex correctly (fix #1817)

* fix: correctly render gradients with clip & repeat (fix #1773)

* fix: webkit-gradient function support

* fix: implement radial gradients

* fix: text-decoration rendering

* fix: missing scroll positions for elements

* ci: fix ios 11 tests

* fix: ie logging

* ci: improve device availability logging

* fix: lint errors

* ci: update to ios 12

* fix: check for console availability

* ci: fix build dependency

* test: update text reftests

* fix: window reference for unit tests

* feat: add hsl/hsla color support

* fix: render options

* fix: CSSKeyframesRule cssText Permission Denied on Internet Explorer 11 (#1830)

* fix: option lint

* fix: list type rendering

* test: fix platform import

* fix: ie css parsing for numbers

* ci: add minified build

* fix: form element rendering

* fix: iframe rendering

* fix: re-introduce experimental foreignobject renderer

* fix: text-shadow rendering

* feat: improve logging

* fix: unit test logging

* fix: cleanup resources

* test: update overflow scrolling to work with ie

* build: update build to include typings

* fix: do not parse select element children

* test: fix onclone test to work with older IEs

* test: reduce reftest canvas sizes

* test: remove dynamic setUp from list tests

* test: update linear-gradient tests

* build: remove old source files

* build: update docs dependencies

* build: fix typescript definition path

* ci: include test.js on docs website
This commit is contained in:
Niklas von Hertzen
2019-05-25 15:54:41 -07:00
committed by GitHub
parent 20a797cbeb
commit 522a443055
221 changed files with 13668 additions and 23699 deletions

View File

@@ -0,0 +1,225 @@
import {deepStrictEqual, fail} from 'assert';
import {FEATURES} from '../features';
import {createMockContext, proxy} from './mock-context';
const images: ImageMock[] = [];
const xhr: XMLHttpRequestMock[] = [];
const sleep = async (timeout: number) => await new Promise(resolve => setTimeout(resolve, timeout));
class ImageMock {
src?: string;
crossOrigin?: string;
onload?: () => {};
constructor() {
images.push(this);
}
}
class XMLHttpRequestMock {
sent: boolean;
status: number;
timeout: number;
method?: string;
url?: string;
response?: string;
onload?: () => {};
ontimeout?: () => {};
constructor() {
this.sent = false;
this.status = 500;
this.timeout = 5000;
xhr.push(this);
}
async load(status: number, response: string) {
this.response = response;
this.status = status;
if (this.onload) {
this.onload();
}
await sleep(0);
}
open(method: string, url: string) {
this.method = method;
this.url = url;
}
send() {
this.sent = true;
}
}
Object.defineProperty(global, 'Image', {value: ImageMock, writable: true});
Object.defineProperty(global, 'XMLHttpRequest', {
value: XMLHttpRequestMock,
writable: true
});
const setFeatures = (opts: {[key: string]: boolean} = {}) => {
const defaults: {[key: string]: boolean} = {
SUPPORT_SVG_DRAWING: true,
SUPPORT_CORS_IMAGES: true,
SUPPORT_CORS_XHR: true,
SUPPORT_RESPONSE_TYPE: false
};
Object.keys(defaults).forEach(key => {
Object.defineProperty(FEATURES, key, {
value: typeof opts[key] === 'boolean' ? opts[key] : defaults[key],
writable: true
});
});
};
describe('cache-storage', () => {
beforeEach(() => setFeatures());
afterEach(() => {
xhr.splice(0, xhr.length);
images.splice(0, images.length);
});
it('addImage adds images to cache', async () => {
const cache = createMockContext('http://example.com', {proxy: null});
await cache.addImage('http://example.com/test.jpg');
await cache.addImage('http://example.com/test2.jpg');
deepStrictEqual(images.length, 2);
deepStrictEqual(images[0].src, 'http://example.com/test.jpg');
deepStrictEqual(images[1].src, 'http://example.com/test2.jpg');
});
it('addImage should not add duplicate entries', async () => {
const cache = createMockContext('http://example.com');
await cache.addImage('http://example.com/test.jpg');
await cache.addImage('http://example.com/test.jpg');
deepStrictEqual(images.length, 1);
deepStrictEqual(images[0].src, 'http://example.com/test.jpg');
});
describe('svg', () => {
it('should add svg images correctly', async () => {
const cache = createMockContext('http://example.com');
await cache.addImage('http://example.com/test.svg');
await cache.addImage('http://example.com/test2.svg');
deepStrictEqual(images.length, 2);
deepStrictEqual(images[0].src, 'http://example.com/test.svg');
deepStrictEqual(images[1].src, 'http://example.com/test2.svg');
});
it('should omit svg images if not supported', async () => {
setFeatures({SUPPORT_SVG_DRAWING: false});
const cache = createMockContext('http://example.com');
await cache.addImage('http://example.com/test.svg');
await cache.addImage('http://example.com/test2.svg');
deepStrictEqual(images.length, 0);
});
});
describe('cross-origin', () => {
it('addImage should not add images it cannot load/render', async () => {
const cache = createMockContext('http://example.com', {
proxy: undefined
});
await cache.addImage('http://html2canvas.hertzen.com/test.jpg');
deepStrictEqual(images.length, 0);
});
it('addImage should add images if tainting enabled', async () => {
const cache = createMockContext('http://example.com', {
allowTaint: true,
proxy: undefined
});
await cache.addImage('http://html2canvas.hertzen.com/test.jpg');
deepStrictEqual(images.length, 1);
deepStrictEqual(images[0].src, 'http://html2canvas.hertzen.com/test.jpg');
deepStrictEqual(images[0].crossOrigin, undefined);
});
it('addImage should add images if cors enabled', async () => {
const cache = createMockContext('http://example.com', {useCORS: true});
await cache.addImage('http://html2canvas.hertzen.com/test.jpg');
deepStrictEqual(images.length, 1);
deepStrictEqual(images[0].src, 'http://html2canvas.hertzen.com/test.jpg');
deepStrictEqual(images[0].crossOrigin, 'anonymous');
});
it('addImage should not add images if cors enabled but not supported', async () => {
setFeatures({SUPPORT_CORS_IMAGES: false});
const cache = createMockContext('http://example.com', {
useCORS: true,
proxy: undefined
});
await cache.addImage('http://html2canvas.hertzen.com/test.jpg');
deepStrictEqual(images.length, 0);
});
it('addImage should not add images to proxy if cors enabled', async () => {
const cache = createMockContext('http://example.com', {useCORS: true});
await cache.addImage('http://html2canvas.hertzen.com/test.jpg');
deepStrictEqual(images.length, 1);
deepStrictEqual(images[0].src, 'http://html2canvas.hertzen.com/test.jpg');
deepStrictEqual(images[0].crossOrigin, 'anonymous');
});
it('addImage should use proxy ', async () => {
const cache = createMockContext('http://example.com');
await cache.addImage('http://html2canvas.hertzen.com/test.jpg');
deepStrictEqual(xhr.length, 1);
deepStrictEqual(
xhr[0].url,
`${proxy}?url=${encodeURIComponent('http://html2canvas.hertzen.com/test.jpg')}&responseType=text`
);
await xhr[0].load(200, '<data response>');
deepStrictEqual(images.length, 1);
deepStrictEqual(images[0].src, '<data response>');
});
it('proxy should respect imageTimeout', async () => {
const cache = createMockContext('http://example.com', {
imageTimeout: 10
});
await cache.addImage('http://html2canvas.hertzen.com/test.jpg');
deepStrictEqual(xhr.length, 1);
deepStrictEqual(
xhr[0].url,
`${proxy}?url=${encodeURIComponent('http://html2canvas.hertzen.com/test.jpg')}&responseType=text`
);
deepStrictEqual(xhr[0].timeout, 10);
if (xhr[0].ontimeout) {
xhr[0].ontimeout();
}
try {
await cache.match('http://html2canvas.hertzen.com/test.jpg');
fail('Expected result to timeout');
} catch (e) {}
});
});
it('match should return cache entry', async () => {
const cache = createMockContext('http://example.com');
await cache.addImage('http://example.com/test.jpg');
if (images[0].onload) {
images[0].onload();
}
const response = await cache.match('http://example.com/test.jpg');
deepStrictEqual(response.src, 'http://example.com/test.jpg');
});
it('image should respect imageTimeout', async () => {
const cache = createMockContext('http://example.com', {imageTimeout: 10});
cache.addImage('http://example.com/test.jpg');
try {
await cache.match('http://example.com/test.jpg');
fail('Expected result to timeout');
} catch (e) {}
});
});

View File

@@ -0,0 +1,45 @@
import {CacheStorage} from '../cache-storage';
import {URL} from 'url';
import {Logger} from '../logger';
export const proxy = 'http://example.com/proxy';
export const createMockContext = (origin: string, opts = {}) => {
const context = {
location: {
href: origin
},
document: {
createElement(_name: string) {
let _href = '';
return {
set href(value: string) {
_href = value;
},
get href() {
return _href;
},
get protocol() {
return new URL(_href).protocol;
},
get hostname() {
return new URL(_href).hostname;
},
get port() {
return new URL(_href).port;
}
};
}
}
};
CacheStorage.setContext(context as Window);
Logger.create('test');
return CacheStorage.create('test', {
imageTimeout: 0,
useCORS: false,
allowTaint: false,
proxy,
...opts
});
};

1
src/core/bitwise.ts Normal file
View File

@@ -0,0 +1 @@
export const contains = (bit: number, value: number): boolean => (bit & value) !== 0;

207
src/core/cache-storage.ts Normal file
View File

@@ -0,0 +1,207 @@
import {FEATURES} from './features';
import {Logger} from './logger';
export class CacheStorage {
private static _caches: {[key: string]: Cache} = {};
private static _link?: HTMLAnchorElement;
private static _origin: string = 'about:blank';
private static _current: Cache | null = null;
static create(name: string, options: ResourceOptions): Cache {
return (CacheStorage._caches[name] = new Cache(name, options));
}
static destroy(name: string): void {
delete CacheStorage._caches[name];
}
static open(name: string): Cache {
const cache = CacheStorage._caches[name];
if (typeof cache !== 'undefined') {
return cache;
}
throw new Error(`Cache with key "${name}" not found`);
}
static getOrigin(url: string): string {
const link = CacheStorage._link;
if (!link) {
return 'about:blank';
}
link.href = url;
link.href = link.href; // IE9, LOL! - http://jsfiddle.net/niklasvh/2e48b/
return link.protocol + link.hostname + link.port;
}
static isSameOrigin(src: string): boolean {
return CacheStorage.getOrigin(src) === CacheStorage._origin;
}
static setContext(window: Window) {
CacheStorage._link = window.document.createElement('a');
CacheStorage._origin = CacheStorage.getOrigin(window.location.href);
}
static getInstance(): Cache {
const current = CacheStorage._current;
if (current === null) {
throw new Error(`No cache instance attached`);
}
return current;
}
static attachInstance(cache: Cache) {
CacheStorage._current = cache;
}
static detachInstance() {
CacheStorage._current = null;
}
}
interface ResourceOptions {
imageTimeout: number;
useCORS: boolean;
allowTaint: boolean;
proxy?: string;
}
export class Cache {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private readonly _cache: {[key: string]: Promise<any>};
private readonly _options: ResourceOptions;
private readonly id: string;
constructor(id: string, options: ResourceOptions) {
this.id = id;
this._options = options;
this._cache = {};
}
addImage(src: string): Promise<void> {
const result = Promise.resolve();
if (this.has(src)) {
return result;
}
if (isBlobImage(src) || isRenderable(src)) {
this._cache[src] = this.loadImage(src);
return result;
}
return result;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
match(src: string): Promise<any> {
return this._cache[src];
}
private async loadImage(key: string) {
const isSameOrigin = CacheStorage.isSameOrigin(key);
const useCORS =
!isInlineImage(key) && this._options.useCORS === true && FEATURES.SUPPORT_CORS_IMAGES && !isSameOrigin;
const useProxy =
!isInlineImage(key) &&
!isSameOrigin &&
typeof this._options.proxy === 'string' &&
FEATURES.SUPPORT_CORS_XHR &&
!useCORS;
if (!isSameOrigin && this._options.allowTaint === false && !isInlineImage(key) && !useProxy && !useCORS) {
return;
}
let src = key;
if (useProxy) {
src = await this.proxy(src);
}
Logger.getInstance(this.id).debug(`Added image ${key.substring(0, 256)}`);
return await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
//ios safari 10.3 taints canvas with data urls unless crossOrigin is set to anonymous
if (isInlineBase64Image(src) || useCORS) {
img.crossOrigin = 'anonymous';
}
img.src = src;
if (img.complete === true) {
// Inline XML images may fail to parse, throwing an Error later on
setTimeout(() => resolve(img), 500);
}
if (this._options.imageTimeout > 0) {
setTimeout(
() => reject(`Timed out (${this._options.imageTimeout}ms) loading image`),
this._options.imageTimeout
);
}
});
}
private has(key: string): boolean {
return typeof this._cache[key] !== 'undefined';
}
keys(): Promise<string[]> {
return Promise.resolve(Object.keys(this._cache));
}
private proxy(src: string): Promise<string> {
const proxy = this._options.proxy;
if (!proxy) {
throw new Error('No proxy defined');
}
const key = src.substring(0, 256);
return new Promise((resolve, reject) => {
const responseType = FEATURES.SUPPORT_RESPONSE_TYPE ? 'blob' : 'text';
const xhr = new XMLHttpRequest();
xhr.onload = () => {
if (xhr.status === 200) {
if (responseType === 'text') {
resolve(xhr.response);
} else {
const reader = new FileReader();
reader.addEventListener('load', () => resolve(reader.result as string), false);
reader.addEventListener('error', e => reject(e), false);
reader.readAsDataURL(xhr.response);
}
} else {
reject(`Failed to proxy resource ${key} with status code ${xhr.status}`);
}
};
xhr.onerror = reject;
xhr.open('GET', `${proxy}?url=${encodeURIComponent(src)}&responseType=${responseType}`);
if (responseType !== 'text' && xhr instanceof XMLHttpRequest) {
xhr.responseType = responseType;
}
if (this._options.imageTimeout) {
const timeout = this._options.imageTimeout;
xhr.timeout = timeout;
xhr.ontimeout = () => reject(`Timed out (${timeout}ms) proxying ${key}`);
}
xhr.send();
});
}
}
const INLINE_SVG = /^data:image\/svg\+xml/i;
const INLINE_BASE64 = /^data:image\/.*;base64,/i;
const INLINE_IMG = /^data:image\/.*/i;
const isRenderable = (src: string): boolean => FEATURES.SUPPORT_SVG_DRAWING || !isSVG(src);
const isInlineImage = (src: string): boolean => INLINE_IMG.test(src);
const isInlineBase64Image = (src: string): boolean => INLINE_BASE64.test(src);
const isBlobImage = (src: string): boolean => src.substr(0, 4) === 'blob';
const isSVG = (src: string): boolean => src.substr(-3).toLowerCase() === 'svg' || INLINE_SVG.test(src);

162
src/core/features.ts Normal file
View File

@@ -0,0 +1,162 @@
const testRangeBounds = (document: Document) => {
const TEST_HEIGHT = 123;
if (document.createRange) {
const range = document.createRange();
if (range.getBoundingClientRect) {
const testElement = document.createElement('boundtest');
testElement.style.height = `${TEST_HEIGHT}px`;
testElement.style.display = 'block';
document.body.appendChild(testElement);
range.selectNode(testElement);
const rangeBounds = range.getBoundingClientRect();
const rangeHeight = Math.round(rangeBounds.height);
document.body.removeChild(testElement);
if (rangeHeight === TEST_HEIGHT) {
return true;
}
}
}
return false;
};
const testCORS = (): boolean => typeof new Image().crossOrigin !== 'undefined';
const testResponseType = (): boolean => typeof new XMLHttpRequest().responseType === 'string';
const testSVG = (document: Document): boolean => {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
return false;
}
img.src = `data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'></svg>`;
try {
ctx.drawImage(img, 0, 0);
canvas.toDataURL();
} catch (e) {
return false;
}
return true;
};
const isGreenPixel = (data: Uint8ClampedArray): boolean =>
data[0] === 0 && data[1] === 255 && data[2] === 0 && data[3] === 255;
const testForeignObject = (document: Document): Promise<boolean> => {
const canvas = document.createElement('canvas');
const size = 100;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) {
return Promise.reject(false);
}
ctx.fillStyle = 'rgb(0, 255, 0)';
ctx.fillRect(0, 0, size, size);
const img = new Image();
const greenImageSrc = canvas.toDataURL();
img.src = greenImageSrc;
const svg = createForeignObjectSVG(size, size, 0, 0, img);
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, size, size);
return loadSerializedSVG(svg)
.then((img: HTMLImageElement) => {
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(0, 0, size, size).data;
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, size, size);
const node = document.createElement('div');
node.style.backgroundImage = `url(${greenImageSrc})`;
node.style.height = `${size}px`;
// Firefox 55 does not render inline <img /> tags
return isGreenPixel(data)
? loadSerializedSVG(createForeignObjectSVG(size, size, 0, 0, node))
: Promise.reject(false);
})
.then((img: HTMLImageElement) => {
ctx.drawImage(img, 0, 0);
// Edge does not render background-images
return isGreenPixel(ctx.getImageData(0, 0, size, size).data);
})
.catch(() => false);
};
export const createForeignObjectSVG = (width: number, height: number, x: number, y: number, node: Node) => {
const xmlns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(xmlns, 'svg');
const foreignObject = document.createElementNS(xmlns, 'foreignObject');
svg.setAttributeNS(null, 'width', width.toString());
svg.setAttributeNS(null, 'height', height.toString());
foreignObject.setAttributeNS(null, 'width', '100%');
foreignObject.setAttributeNS(null, 'height', '100%');
foreignObject.setAttributeNS(null, 'x', x.toString());
foreignObject.setAttributeNS(null, 'y', y.toString());
foreignObject.setAttributeNS(null, 'externalResourcesRequired', 'true');
svg.appendChild(foreignObject);
foreignObject.appendChild(node);
return svg;
};
export const loadSerializedSVG = (svg: Node): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
});
};
export const FEATURES = {
get SUPPORT_RANGE_BOUNDS() {
'use strict';
const value = testRangeBounds(document);
Object.defineProperty(FEATURES, 'SUPPORT_RANGE_BOUNDS', {value});
return value;
},
get SUPPORT_SVG_DRAWING() {
'use strict';
const value = testSVG(document);
Object.defineProperty(FEATURES, 'SUPPORT_SVG_DRAWING', {value});
return value;
},
get SUPPORT_FOREIGNOBJECT_DRAWING() {
'use strict';
const value =
typeof Array.from === 'function' && typeof window.fetch === 'function'
? testForeignObject(document)
: Promise.resolve(false);
Object.defineProperty(FEATURES, 'SUPPORT_FOREIGNOBJECT_DRAWING', {value});
return value;
},
get SUPPORT_CORS_IMAGES() {
'use strict';
const value = testCORS();
Object.defineProperty(FEATURES, 'SUPPORT_CORS_IMAGES', {value});
return value;
},
get SUPPORT_RESPONSE_TYPE() {
'use strict';
const value = testResponseType();
Object.defineProperty(FEATURES, 'SUPPORT_RESPONSE_TYPE', {value});
return value;
},
get SUPPORT_CORS_XHR() {
'use strict';
const value = 'withCredentials' in new XMLHttpRequest();
Object.defineProperty(FEATURES, 'SUPPORT_CORS_XHR', {value});
return value;
}
};

62
src/core/logger.ts Normal file
View File

@@ -0,0 +1,62 @@
export class Logger {
static instances: {[key: string]: Logger} = {};
private readonly id: string;
private readonly start: number;
constructor(id: string) {
this.id = id;
this.start = Date.now();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
debug(...args: any) {
// eslint-disable-next-line no-console
if (typeof window !== 'undefined' && window.console && typeof console.debug === 'function') {
// eslint-disable-next-line no-console
console.debug(this.id, `${this.getTime()}ms`, ...args);
} else {
this.info(...args);
}
}
getTime(): number {
return Date.now() - this.start;
}
static create(id: string) {
Logger.instances[id] = new Logger(id);
}
static destroy(id: string) {
delete Logger.instances[id];
}
static getInstance(id: string): Logger {
const instance = Logger.instances[id];
if (typeof instance === 'undefined') {
throw new Error(`No logger instance found with id ${id}`);
}
return instance;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
info(...args: any) {
// eslint-disable-next-line no-console
if (typeof window !== 'undefined' && window.console && typeof console.info === 'function') {
// eslint-disable-next-line no-console
console.info(this.id, `${this.getTime()}ms`, ...args);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error(...args: any) {
// eslint-disable-next-line no-console
if (typeof window !== 'undefined' && window.console && typeof console.error === 'function') {
// eslint-disable-next-line no-console
console.error(this.id, `${this.getTime()}ms`, ...args);
} else {
this.info(...args);
}
}
}

1
src/core/util.ts Normal file
View File

@@ -0,0 +1 @@
export const SMALL_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';