From 5956f3985027c8c59551560c0669cf6b0f879047 Mon Sep 17 00:00:00 2001 From: Angelina Nguyen Date: Sun, 11 Feb 2024 20:45:19 +1100 Subject: [PATCH] see pr: https://github.com/niklasvh/html2canvas/pull/2955 edited canvas-renderer to accommodate other pr for object fit (b23fc4dd86700cbb909f4bccc172a371d45f33b6) + removed comment linking to firefox issue as it is now resolved --- src/core/__mocks__/context.ts | 7 +-- src/core/__tests__/cache-storage.ts | 60 +++++++++++-------- src/core/cache-storage.ts | 17 +++--- src/core/features.ts | 34 +++++++++-- src/dom/node-parser.ts | 1 + .../iframe-element-container.ts | 4 +- .../image-element-container.ts | 40 ++++++++++++- .../svg-element-container.ts | 5 +- src/render/canvas/canvas-renderer.ts | 55 ++++++++++------- src/render/canvas/foreignobject-renderer.ts | 12 +--- 10 files changed, 151 insertions(+), 84 deletions(-) diff --git a/src/core/__mocks__/context.ts b/src/core/__mocks__/context.ts index 3a03a8d91..b5fe7dc20 100644 --- a/src/core/__mocks__/context.ts +++ b/src/core/__mocks__/context.ts @@ -9,10 +9,9 @@ export class Context { constructor() { this.cache = { - addImage: jest.fn().mockImplementation((src: string): Promise => { - const result = Promise.resolve(); - this._cache[src] = result; - return result; + addImage: jest.fn().mockImplementation((src: string): boolean => { + this._cache[src] = Promise.resolve(); + return true; }) }; } diff --git a/src/core/__tests__/cache-storage.ts b/src/core/__tests__/cache-storage.ts index 7ea4dced9..1fa5d8524 100644 --- a/src/core/__tests__/cache-storage.ts +++ b/src/core/__tests__/cache-storage.ts @@ -125,88 +125,88 @@ 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'); @@ -214,7 +214,7 @@ describe('cache-storage', () => { 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, @@ -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( @@ -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(); @@ -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); + }); }); diff --git a/src/core/cache-storage.ts b/src/core/cache-storage.ts index 2be98edeb..13cc5c2bc 100644 --- a/src/core/cache-storage.ts +++ b/src/core/cache-storage.ts @@ -39,20 +39,15 @@ export class Cache { constructor(private readonly context: Context, private readonly _options: ResourceOptions) {} - addImage(src: string): Promise { - 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 @@ -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); } diff --git a/src/core/features.ts b/src/core/features.ts index 64e8aeab9..78286ce17 100644 --- a/src/core/features.ts +++ b/src/core/features.ts @@ -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; @@ -156,15 +157,38 @@ export const createForeignObjectSVG = ( return svg; }; -export const loadSerializedSVG = (svg: Node): Promise => { - 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 => + 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 { diff --git a/src/dom/node-parser.ts b/src/dom/node-parser.ts index 5f38a2411..8e8503cd6 100644 --- a/src/dom/node-parser.ts +++ b/src/dom/node-parser.ts @@ -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'; diff --git a/src/dom/replaced-elements/iframe-element-container.ts b/src/dom/replaced-elements/iframe-element-container.ts index 1ad8d852b..3482976d0 100644 --- a/src/dom/replaced-elements/iframe-element-container.ts +++ b/src/dom/replaced-elements/iframe-element-container.ts @@ -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 ( diff --git a/src/dom/replaced-elements/image-element-container.ts b/src/dom/replaced-elements/image-element-container.ts index 51ef1b123..291abb527 100644 --- a/src/dom/replaced-elements/image-element-container.ts +++ b/src/dom/replaced-elements/image-element-container.ts @@ -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); } } diff --git a/src/dom/replaced-elements/svg-element-container.ts b/src/dom/replaced-elements/svg-element-container.ts index 6d6977012..86c92cdac 100644 --- a/src/dom/replaced-elements/svg-element-container.ts +++ b/src/dom/replaced-elements/svg-element-container.ts @@ -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; @@ -9,7 +10,7 @@ export class SVGElementContainer extends ElementContainer { constructor(context: Context, img: SVGSVGElement) { super(context, img); - const s = new XMLSerializer(); + const bounds = parseBounds(context, img); const originPosition: string = img.style.position; img.setAttribute('width', `${bounds.width}px`); @@ -20,7 +21,7 @@ export class SVGElementContainer extends ElementContainer { // so, it is necessary to eliminate positioning before serialization. img.style.position = 'initial'; - this.svg = `data:image/svg+xml,${encodeURIComponent(s.serializeToString(img))}`; + this.svg = serializeSvg(img); // reset position img.style.position = originPosition; diff --git a/src/render/canvas/canvas-renderer.ts b/src/render/canvas/canvas-renderer.ts index a7c933d69..de5fec029 100644 --- a/src/render/canvas/canvas-renderer.ts +++ b/src/render/canvas/canvas-renderer.ts @@ -271,31 +271,41 @@ export class CanvasRenderer extends Renderer { curves: BoundCurves, image: HTMLImageElement | HTMLCanvasElement ): void { - if (image && container.intrinsicWidth > 0 && container.intrinsicHeight > 0) { + const isContainerWSizes = container.intrinsicWidth > 0 && container.intrinsicHeight > 0; + const isSVGContainer = + container instanceof SVGElementContainer || (container instanceof ImageElementContainer && container.isSVG); + + if (image && (isContainerWSizes || isSVGContainer)) { const box = contentBox(container); const path = calculatePaddingBoxPath(curves); this.path(path); - const {src, dest} = calculateObjectFitBounds( - container.styles.objectFit, - container.styles.objectPosition, - container.intrinsicWidth, - container.intrinsicHeight, - box.width, - box.height - ); - this.ctx.save(); - this.ctx.clip(); - this.ctx.drawImage( - image, - src.left, - src.top, - src.width, - src.height, - box.left + dest.left, - box.top + dest.top, - dest.width, - dest.height - ); + if (isContainerWSizes) { + const {src, dest} = calculateObjectFitBounds( + container.styles.objectFit, + container.styles.objectPosition, + container.intrinsicWidth, + container.intrinsicHeight, + box.width, + box.height + ); + this.ctx.save(); + this.ctx.clip(); + this.ctx.drawImage( + image, + src.left, + src.top, + src.width, + src.height, + box.left + dest.left, + box.top + dest.top, + dest.width, + dest.height + ); + } else { + this.ctx.save(); + this.ctx.clip(); + this.ctx.drawImage(image, box.left, box.top, box.width, box.height); + } this.ctx.restore(); } } @@ -312,6 +322,7 @@ export class CanvasRenderer extends Renderer { if (container instanceof ImageElementContainer) { try { const image = await this.context.cache.match(container.src); + container.setup(image); this.renderReplacedElement(container, curves, image); } catch (e) { this.context.logger.error(`Error loading image ${container.src}`); diff --git a/src/render/canvas/foreignobject-renderer.ts b/src/render/canvas/foreignobject-renderer.ts index a6d1e3a64..23fc840c7 100644 --- a/src/render/canvas/foreignobject-renderer.ts +++ b/src/render/canvas/foreignobject-renderer.ts @@ -1,5 +1,5 @@ import {RenderConfigurations} from './canvas-renderer'; -import {createForeignObjectSVG} from '../../core/features'; +import {createForeignObjectSVG, loadSerializedSVG} from '../../core/features'; import {asString} from '../../css/types/color'; import {Renderer} from '../renderer'; import {Context} from '../../core/context'; @@ -48,13 +48,3 @@ export class ForeignObjectRenderer extends Renderer { } } -export const loadSerializedSVG = (svg: Node): Promise => - 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))}`; - });