Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Various fixes. #2955

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24,944 changes: 35 additions & 24,909 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 3 additions & 4 deletions src/core/__mocks__/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@ export class Context {

constructor() {
this.cache = {
addImage: jest.fn().mockImplementation((src: string): Promise<void> => {
const result = Promise.resolve();
this._cache[src] = result;
return result;
addImage: jest.fn().mockImplementation((src: string): boolean => {
this._cache[src] = Promise.resolve();
return true;
})
};
}
Expand Down
60 changes: 35 additions & 25 deletions src/core/__tests__/cache-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,96 +125,96 @@ describe('cache-storage', () => {
xhr.splice(0, xhr.length);
images.splice(0, images.length);
});
it('addImage adds images to cache', async () => {
it('addImage adds images to cache', () => {
const {cache} = createMockContext('http://example.com', {proxy: null});
await cache.addImage('http://example.com/test.jpg');
await cache.addImage('http://example.com/test2.jpg');
cache.addImage('http://example.com/test.jpg');
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 () => {
it('addImage should not add duplicate entries', () => {
const {cache} = createMockContext('http://example.com');
await cache.addImage('http://example.com/test.jpg');
await cache.addImage('http://example.com/test.jpg');
cache.addImage('http://example.com/test.jpg');
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 () => {
it('should add svg images correctly', () => {
const {cache} = createMockContext('http://example.com');
await cache.addImage('http://example.com/test.svg');
await cache.addImage('http://example.com/test2.svg');
cache.addImage('http://example.com/test.svg');
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 () => {
it('should omit svg images if not supported', () => {
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');
cache.addImage('http://example.com/test.svg');
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 () => {
it('addImage should not add images it cannot load/render', () => {
const {cache} = createMockContext('http://example.com', {
proxy: undefined
});
await cache.addImage('http://html2canvas.hertzen.com/test.jpg');
cache.addImage('http://html2canvas.hertzen.com/test.jpg');
deepStrictEqual(images.length, 0);
});

it('addImage should add images if tainting enabled', async () => {
it('addImage should add images if tainting enabled', () => {
const {cache} = createMockContext('http://example.com', {
allowTaint: true,
proxy: undefined
});
await cache.addImage('http://html2canvas.hertzen.com/test.jpg');
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 () => {
it('addImage should add images if cors enabled', () => {
const {cache} = createMockContext('http://example.com', {useCORS: true});
await cache.addImage('http://html2canvas.hertzen.com/test.jpg');
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 () => {
it('addImage should not add images if cors enabled but not supported', () => {
setFeatures({SUPPORT_CORS_IMAGES: false});

const {cache} = createMockContext('http://example.com', {
useCORS: true,
proxy: undefined
});
await cache.addImage('http://html2canvas.hertzen.com/test.jpg');
cache.addImage('http://html2canvas.hertzen.com/test.jpg');
deepStrictEqual(images.length, 0);
});

it('addImage should not add images to proxy if cors enabled', async () => {
it('addImage should not add images to proxy if cors enabled', () => {
const {cache} = createMockContext('http://example.com', {useCORS: true});
await cache.addImage('http://html2canvas.hertzen.com/test.jpg');
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');
cache.addImage('http://html2canvas.hertzen.com/test.jpg');
deepStrictEqual(xhr.length, 1);
deepStrictEqual(
xhr[0].url,
Expand All @@ -230,7 +230,7 @@ describe('cache-storage', () => {
const {cache} = createMockContext('http://example.com', {
imageTimeout: 10
});
await cache.addImage('http://html2canvas.hertzen.com/test.jpg');
cache.addImage('http://html2canvas.hertzen.com/test.jpg');

deepStrictEqual(xhr.length, 1);
deepStrictEqual(
Expand All @@ -250,7 +250,7 @@ describe('cache-storage', () => {

it('match should return cache entry', async () => {
const {cache} = createMockContext('http://example.com');
await cache.addImage('http://example.com/test.jpg');
cache.addImage('http://example.com/test.jpg');

if (images[0].onload) {
images[0].onload();
Expand All @@ -270,4 +270,14 @@ describe('cache-storage', () => {
fail('Expected result to timeout');
} catch (e) {}
});

it('addImage should add an inlined image', async () => {
const {cache} = createMockContext('http://example.com', {imageTimeout: 10});
const inlinedImg = `data:image/gif;base64,R0lGODlhEAAOALMAAOazToeHh0tLS/7LZv/0jvb29t/f3//Ub/
/ge8WSLf/rhf/3kdbW1mxsbP//mf///yH5BAAAAAAALAAAAAAQAA4AAARe8L1Ekyky67QZ1hLnjM5UUde0ECwLJoExKcpp
V0aCcGCmTIHEIUEqjgaORCMxIC6e0CcguWw6aFjsVMkkIr7g77ZKPJjPZqIyd7sJAgVGoEGv2xsBxqNgYPj/gAwXEQA7`;
cache.addImage(inlinedImg);

await cache.match(inlinedImg);
});
});
17 changes: 7 additions & 10 deletions src/core/cache-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,15 @@ export class Cache {

constructor(private readonly context: Context, private readonly _options: ResourceOptions) {}

addImage(src: string): Promise<void> {
const result = Promise.resolve();
if (this.has(src)) {
return result;
}

addImage(src: string): boolean {
if (this.has(src)) return true;
if (isBlobImage(src) || isRenderable(src)) {
(this._cache[src] = this.loadImage(src)).catch(() => {
// prevent unhandled rejection
});
return result;
return true;
}

return result;
return false;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -98,7 +93,9 @@ export class Cache {
img.crossOrigin = 'anonymous';
}
img.src = src;
if (img.complete === true) {
if (/^data:/.test(src)) {
resolve(img);
} else if (img.complete === true) {
// Inline XML images may fail to parse, throwing an Error later on
setTimeout(() => resolve(img), 500);
}
Expand Down
34 changes: 29 additions & 5 deletions src/core/features.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {fromCodePoint, toCodePoints} from 'css-line-break';
import {isSVGForeignObjectElement} from '../dom/node-parser';

const testRangeBounds = (document: Document) => {
const TEST_HEIGHT = 123;
Expand Down Expand Up @@ -156,15 +157,38 @@ export const createForeignObjectSVG = (
return svg;
};

export const loadSerializedSVG = (svg: Node): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
export const serializeSvg = (svg: SVGSVGElement | SVGForeignObjectElement, encoding = ''): string => {
const svgPrefix = 'data:image/svg+xml';
const selializedSvg = new XMLSerializer().serializeToString(svg);
const encodedSvg = encoding === 'base64' ? btoa(selializedSvg) : encodeURIComponent(selializedSvg);
return `${svgPrefix}${encoding && `;${encoding}`},${encodedSvg}`;
};

const INLINE_BASE64 = /^data:image\/.*;base64,/i;
export const deserializeSvg = (svg: string): SVGSVGElement | SVGForeignObjectElement => {
const encodedSvg = INLINE_BASE64.test(svg) ? atob(svg) : decodeURIComponent(svg);
const domParser = new DOMParser();
const document = domParser.parseFromString(encodedSvg, 'image/svg+xml');
const parserError = document.querySelector('parsererror');
if (parserError) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Expected 0-1 arguments, but got 2.
throw new Error('Deserialisation failed', {cause: parserError});
}
const {documentElement} = document;
const firstSvgChild = documentElement.firstElementChild;
return firstSvgChild && isSVGForeignObjectElement(firstSvgChild)
? (documentElement as unknown as SVGForeignObjectElement)
: (documentElement as unknown as SVGSVGElement);
};

export const loadSerializedSVG = (svg: SVGSVGElement | SVGForeignObjectElement): Promise<HTMLImageElement> =>
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))}`;
img.src = serializeSvg(svg, 'charset=utf-8');
});
};

export const FEATURES = {
get SUPPORT_RANGE_BOUNDS(): boolean {
Expand Down
1 change: 1 addition & 0 deletions src/dom/node-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const isOLElement = (node: Element): node is HTMLOListElement => node.tag
export const isInputElement = (node: Element): node is HTMLInputElement => node.tagName === 'INPUT';
export const isHTMLElement = (node: Element): node is HTMLHtmlElement => node.tagName === 'HTML';
export const isSVGElement = (node: Element): node is SVGSVGElement => node.tagName === 'svg';
export const isSVGForeignObjectElement = (node: Element): node is SVGSVGElement => node.tagName === 'foreignObject';
export const isBodyElement = (node: Element): node is HTMLBodyElement => node.tagName === 'BODY';
export const isCanvasElement = (node: Element): node is HTMLCanvasElement => node.tagName === 'CANVAS';
export const isVideoElement = (node: Element): node is HTMLVideoElement => node.tagName === 'VIDEO';
Expand Down
4 changes: 2 additions & 2 deletions src/dom/replaced-elements/iframe-element-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export class IFrameElementContainer extends ElementContainer {
constructor(context: Context, iframe: HTMLIFrameElement) {
super(context, iframe);
this.src = iframe.src;
this.width = parseInt(iframe.width, 10) || 0;
this.height = parseInt(iframe.height, 10) || 0;
this.width = parseInt(iframe.width, 10) || iframe.offsetWidth || 0;
this.height = parseInt(iframe.height, 10) || iframe.offsetHeight || 0;
this.backgroundColor = this.styles.backgroundColor;
try {
if (
Expand Down
40 changes: 37 additions & 3 deletions src/dom/replaced-elements/image-element-container.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,50 @@
import {ElementContainer} from '../element-container';
import {Context} from '../../core/context';
import {serializeSvg, deserializeSvg} from '../../core/features';

export class ImageElementContainer extends ElementContainer {
src: string;
intrinsicWidth: number;
intrinsicHeight: number;
intrinsicWidth: number = 0;
intrinsicHeight: number = 0;
isSVG: boolean;

private static SVG = /\.svg(?:\?.*)?$/i;
private static INLINED_SVG = /^data:image\/svg\+xml/i;
private static IS_FIRE_FOX = /firefox/i.test(navigator?.userAgent);

constructor(context: Context, img: HTMLImageElement) {
super(context, img);
this.src = img.currentSrc || img.src;
this.isSVG = this.isSvg() || this.isInlinedSvg();
this.context.cache.addImage(this.src);
}

private isInlinedSvg = () => ImageElementContainer.INLINED_SVG.test(this.src);
private isSvg = () => ImageElementContainer.SVG.test(this.src);

public setup(img: HTMLImageElement) {
if (this.isSvg()) return;

if (this.isInlinedSvg()) {
const [, inlinedSvg] = this.src.split(',');
const svgElement = deserializeSvg(inlinedSvg);
const {
width: {baseVal: widthBaseVal},
height: {baseVal: heightBaseVal}
} = svgElement;

if (ImageElementContainer.IS_FIRE_FOX) {
widthBaseVal.valueAsString = widthBaseVal.value.toString();
heightBaseVal.valueAsString = heightBaseVal.value.toString();
img.src = serializeSvg(svgElement, 'base64');
}

this.intrinsicWidth = widthBaseVal.value;
this.intrinsicHeight = heightBaseVal.value;
return;
}

this.intrinsicWidth = img.naturalWidth;
this.intrinsicHeight = img.naturalHeight;
this.context.cache.addImage(this.src);
}
}
5 changes: 3 additions & 2 deletions src/dom/replaced-elements/svg-element-container.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {ElementContainer} from '../element-container';
import {parseBounds} from '../../css/layout/bounds';
import {Context} from '../../core/context';
import {serializeSvg} from '../../core/features';

export class SVGElementContainer extends ElementContainer {
svg: string;
Expand All @@ -9,12 +10,12 @@ export class SVGElementContainer extends ElementContainer {

constructor(context: Context, img: SVGSVGElement) {
super(context, img);
const s = new XMLSerializer();

const bounds = parseBounds(context, img);
img.setAttribute('width', `${bounds.width}px`);
img.setAttribute('height', `${bounds.height}px`);

this.svg = `data:image/svg+xml,${encodeURIComponent(s.serializeToString(img))}`;
this.svg = serializeSvg(img);
this.intrinsicWidth = img.width.baseVal.value;
this.intrinsicHeight = img.height.baseVal.value;

Expand Down
Loading