From 627d311029d3f6362eca426d8417a7a3f31be30f Mon Sep 17 00:00:00 2001
From: MoyuScript <i@moyu.moe>
Date: Mon, 11 Dec 2017 22:02:51 +0100
Subject: [PATCH 1/2] added support for radial gradients

---
 src/Gradient.js                               | 418 ++++++++++++++++--
 src/Length.js                                 |  32 ++
 src/Renderer.js                               |  21 +-
 src/Util.js                                   |   2 +
 src/renderer/CanvasRenderer.js                |  52 ++-
 src/renderer/RefTestRenderer.js               |  17 +-
 tests/node/gradient.js                        | 151 +++++++
 .../reftests/background/radial-gradient2.html |  51 +++
 8 files changed, 689 insertions(+), 55 deletions(-)
 create mode 100644 tests/node/gradient.js
 create mode 100644 tests/reftests/background/radial-gradient2.html

diff --git a/src/Gradient.js b/src/Gradient.js
index d542b84..78aa405 100644
--- a/src/Gradient.js
+++ b/src/Gradient.js
@@ -3,14 +3,22 @@
 
 import type {BackgroundSource} from './parsing/background';
 import type {Bounds} from './Bounds';
+import NodeContainer from './NodeContainer';
 import {parseAngle} from './Angle';
 import Color from './Color';
-import Length, {LENGTH_TYPE} from './Length';
+import Length, {LENGTH_TYPE, calculateLengthFromValueWithUnit} from './Length';
+import {distance} from './Util';
 
 const SIDE_OR_CORNER = /^(to )?(left|top|right|bottom)( (left|top|right|bottom))?$/i;
 const PERCENTAGE_ANGLES = /^([+-]?\d*\.?\d+)% ([+-]?\d*\.?\d+)%$/i;
 const ENDS_WITH_LENGTH = /(px)|%|( 0)$/i;
-const FROM_TO = /^(from|to)\((.+)\)$/i;
+const FROM_TO_COLORSTOP = /^(from|to|color-stop)\((?:([\d.]+)(%)?,\s*)?(.+?)\)$/i;
+const RADIAL_SHAPE_DEFINITION = /^\s*(circle|ellipse)?\s*((?:([\d.]+)(px|r?em|%)\s*(?:([\d.]+)(px|r?em|%))?)|closest-side|closest-corner|farthest-side|farthest-corner)?\s*(?:at\s*(?:(left|center|right)|([\d.]+)(px|r?em|%))\s+(?:(top|center|bottom)|([\d.]+)(px|r?em|%)))?(?:\s|$)/i;
+
+export type Point = {
+    x: number,
+    y: number
+};
 
 export type Direction = {
     x0: number,
@@ -24,51 +32,72 @@ export type ColorStop = {
     stop: number
 };
 
-export type Gradient = {
+export type LinearGradient = {
+    type: GradientType,
     direction: Direction,
     colorStops: Array<ColorStop>
 };
 
+export type RadialGradient = {
+    type: GradientType,
+    shape: RadialGradientShapeType,
+    center: Point,
+    radius: Point,
+    colorStops: Array<ColorStop>
+};
+
+export const GRADIENT_TYPE = {
+    LINEAR_GRADIENT: 0,
+    RADIAL_GRADIENT: 1
+};
+
+export type GradientType = $Values<typeof GRADIENT_TYPE>;
+
+export const RADIAL_GRADIENT_SHAPE = {
+    CIRCLE: 0,
+    ELLIPSE: 1
+};
+
+export type RadialGradientShapeType = $Values<typeof RADIAL_GRADIENT_SHAPE>;
+
+const LENGTH_FOR_POSITION = {
+    left: new Length('0%'),
+    top: new Length('0%'),
+    center: new Length('50%'),
+    right: new Length('100%'),
+    bottom: new Length('100%')
+};
+
 export const parseGradient = (
+    container: NodeContainer,
     {args, method, prefix}: BackgroundSource,
     bounds: Bounds
-): ?Gradient => {
+): ?LinearGradient | RadialGradient => {
     if (method === 'linear-gradient') {
         return parseLinearGradient(args, bounds, !!prefix);
     } else if (method === 'gradient' && args[0] === 'linear') {
         // TODO handle correct angle
         return parseLinearGradient(
-            ['to bottom'].concat(
-                args
-                    .slice(3)
-                    .map(color => color.match(FROM_TO))
-                    .filter(v => v !== null)
-                    // $FlowFixMe
-                    .map(v => v[2])
-            ),
+            ['to bottom'].concat(transformObsoleteColorStops(args.slice(3))),
             bounds,
             !!prefix
         );
+    } else if (method === 'radial-gradient') {
+        if (prefix === '-webkit-') {
+            args = transformWebkitRadialGradientArgs(args);
+        }
+        return parseRadialGradient(container, args, bounds);
+    } else if (method === 'gradient' && args[0] === 'radial') {
+        return parseRadialGradient(
+            container,
+            transformObsoleteColorStops(transformWebkitRadialGradientArgs(args.slice(1))),
+            bounds
+        );
     }
 };
 
-const parseLinearGradient = (args: Array<string>, bounds: Bounds, hasPrefix: boolean): Gradient => {
-    const angle = parseAngle(args[0]);
-    const HAS_SIDE_OR_CORNER = SIDE_OR_CORNER.test(args[0]);
-    const HAS_DIRECTION = HAS_SIDE_OR_CORNER || angle !== null || PERCENTAGE_ANGLES.test(args[0]);
-    const direction = HAS_DIRECTION
-        ? angle !== null
-          ? calculateGradientDirection(
-                // if there is a prefix, the 0° angle points due East (instead of North per W3C)
-                hasPrefix ? angle - Math.PI * 0.5 : angle,
-                bounds
-            )
-          : HAS_SIDE_OR_CORNER
-            ? parseSideOrCorner(args[0], bounds)
-            : parsePercentageAngle(args[0], bounds)
-        : calculateGradientDirection(Math.PI, bounds);
+const parseColorStops = (args: Array<string>, firstColorStopIndex: number, lineLength: number) => {
     const colorStops = [];
-    const firstColorStopIndex = HAS_DIRECTION ? 1 : 0;
 
     for (let i = firstColorStopIndex; i < args.length; i++) {
         const value = args[i];
@@ -83,16 +112,6 @@ const parseLinearGradient = (args: Array<string>, bounds: Bounds, hasPrefix: boo
         colorStops.push({color, stop});
     }
 
-    // TODO: Fix some inaccuracy with color stops with px values
-    const lineLength = Math.min(
-        Math.sqrt(
-            Math.pow(Math.abs(direction.x0) + Math.abs(direction.x1), 2) +
-                Math.pow(Math.abs(direction.y0) + Math.abs(direction.y1), 2)
-        ),
-        bounds.width * 2,
-        bounds.height * 2
-    );
-
     const absoluteValuedColorStops = colorStops.map(({color, stop}) => {
         return {
             color,
@@ -123,9 +142,108 @@ const parseLinearGradient = (args: Array<string>, bounds: Bounds, hasPrefix: boo
         }
     }
 
+    return absoluteValuedColorStops;
+};
+
+const parseLinearGradient = (
+    args: Array<string>,
+    bounds: Bounds,
+    hasPrefix: boolean
+): LinearGradient => {
+    const angle = parseAngle(args[0]);
+    const HAS_SIDE_OR_CORNER = SIDE_OR_CORNER.test(args[0]);
+    const HAS_DIRECTION = HAS_SIDE_OR_CORNER || angle !== null || PERCENTAGE_ANGLES.test(args[0]);
+    const direction = HAS_DIRECTION
+        ? angle !== null
+          ? calculateGradientDirection(
+                // if there is a prefix, the 0° angle points due East (instead of North per W3C)
+                hasPrefix ? angle - Math.PI * 0.5 : angle,
+                bounds
+            )
+          : HAS_SIDE_OR_CORNER
+            ? parseSideOrCorner(args[0], bounds)
+            : parsePercentageAngle(args[0], bounds)
+        : calculateGradientDirection(Math.PI, bounds);
+    const firstColorStopIndex = HAS_DIRECTION ? 1 : 0;
+
+    // TODO: Fix some inaccuracy with color stops with px values
+    const lineLength = Math.min(
+        distance(
+            Math.abs(direction.x0) + Math.abs(direction.x1),
+            Math.abs(direction.y0) + Math.abs(direction.y1)
+        ),
+        bounds.width * 2,
+        bounds.height * 2
+    );
+
     return {
+        type: GRADIENT_TYPE.LINEAR_GRADIENT,
         direction,
-        colorStops: absoluteValuedColorStops
+        colorStops: parseColorStops(args, firstColorStopIndex, lineLength)
+    };
+};
+
+const parseRadialGradient = (
+    container: NodeContainer,
+    args: Array<string>,
+    bounds: Bounds
+): RadialGradient => {
+    const m = args[0].match(RADIAL_SHAPE_DEFINITION);
+    const shape =
+        m &&
+        (m[1] === 'circle' || // explicit shape specification
+            (m[3] !== undefined && m[5] === undefined)) // only one radius coordinate
+            ? RADIAL_GRADIENT_SHAPE.CIRCLE
+            : RADIAL_GRADIENT_SHAPE.ELLIPSE;
+    const radius = {};
+    const center = {};
+
+    if (m) {
+        // Radius
+        if (m[3] !== undefined) {
+            radius.x = calculateLengthFromValueWithUnit(container, m[3], m[4]).getAbsoluteValue(
+                bounds.width
+            );
+        }
+
+        if (m[5] !== undefined) {
+            radius.y = calculateLengthFromValueWithUnit(container, m[5], m[6]).getAbsoluteValue(
+                bounds.height
+            );
+        }
+
+        // Position
+        if (m[7]) {
+            center.x = LENGTH_FOR_POSITION[m[7].toLowerCase()];
+        } else if (m[8] !== undefined) {
+            center.x = calculateLengthFromValueWithUnit(container, m[8], m[9]);
+        }
+
+        if (m[10]) {
+            center.y = LENGTH_FOR_POSITION[m[10].toLowerCase()];
+        } else if (m[11] !== undefined) {
+            center.y = calculateLengthFromValueWithUnit(container, m[11], m[12]);
+        }
+    }
+
+    const gradientCenter = {
+        x: center.x === undefined ? bounds.width / 2 : center.x.getAbsoluteValue(bounds.width),
+        y: center.y === undefined ? bounds.height / 2 : center.y.getAbsoluteValue(bounds.height)
+    };
+    const gradientRadius = calculateRadius(
+        (m && m[2]) || 'farthest-corner',
+        shape,
+        gradientCenter,
+        radius,
+        bounds
+    );
+
+    return {
+        type: GRADIENT_TYPE.RADIAL_GRADIENT,
+        shape,
+        center: gradientCenter,
+        radius: gradientRadius,
+        colorStops: parseColorStops(args, m ? 1 : 0, Math.min(gradientRadius.x, gradientRadius.y))
     };
 };
 
@@ -146,9 +264,7 @@ const calculateGradientDirection = (radian: number, bounds: Bounds): Direction =
 };
 
 const parseTopRight = (bounds: Bounds) =>
-    Math.acos(
-        bounds.width / 2 / (Math.sqrt(Math.pow(bounds.width, 2) + Math.pow(bounds.height, 2)) / 2)
-    );
+    Math.acos(bounds.width / 2 / (distance(bounds.width, bounds.height) / 2));
 
 const parseSideOrCorner = (side: string, bounds: Bounds): Direction => {
     switch (side) {
@@ -194,3 +310,221 @@ const parsePercentageAngle = (angle: string, bounds: Bounds): Direction => {
 
     return calculateGradientDirection(Math.atan(isNaN(ratio) ? 1 : ratio) + Math.PI / 2, bounds);
 };
+
+const findCorner = (bounds: Bounds, x: number, y: number, closest: boolean): Point => {
+    var corners = [
+        {x: 0, y: 0},
+        {x: 0, y: bounds.height},
+        {x: bounds.width, y: 0},
+        {x: bounds.width, y: bounds.height}
+    ];
+
+    let optimumDistance = closest ? Infinity : -Infinity;
+    let optimumCorner = null;
+
+    for (let corner of corners) {
+        const d = distance(x - corner.x, y - corner.y);
+        if (closest ? d < optimumDistance : d > optimumDistance) {
+            optimumDistance = d;
+            optimumCorner = corner;
+        }
+    }
+
+    // $FlowFixMe
+    return optimumCorner;
+};
+
+const calculateRadius = (
+    extent: string,
+    shape: RadialGradientShapeType,
+    center: Point,
+    radius: Point,
+    bounds: Bounds
+): Point => {
+    const x = center.x;
+    const y = center.y;
+    let rx = 0;
+    let ry = 0;
+
+    switch (extent) {
+        case 'closest-side':
+            // The ending shape is sized so that that it exactly meets the side of the gradient box closest to the gradient’s center.
+            // If the shape is an ellipse, it exactly meets the closest side in each dimension.
+            if (shape === RADIAL_GRADIENT_SHAPE.CIRCLE) {
+                rx = ry = Math.min(
+                    Math.abs(x),
+                    Math.abs(x - bounds.width),
+                    Math.abs(y),
+                    Math.abs(y - bounds.height)
+                );
+            } else if (shape === RADIAL_GRADIENT_SHAPE.ELLIPSE) {
+                rx = Math.min(Math.abs(x), Math.abs(x - bounds.width));
+                ry = Math.min(Math.abs(y), Math.abs(y - bounds.height));
+            }
+            break;
+
+        case 'closest-corner':
+            // The ending shape is sized so that that it passes through the corner of the gradient box closest to the gradient’s center.
+            // If the shape is an ellipse, the ending shape is given the same aspect-ratio it would have if closest-side were specified.
+            if (shape === RADIAL_GRADIENT_SHAPE.CIRCLE) {
+                rx = ry = Math.min(
+                    distance(x, y),
+                    distance(x, y - bounds.height),
+                    distance(x - bounds.width, y),
+                    distance(x - bounds.width, y - bounds.height)
+                );
+            } else if (shape === RADIAL_GRADIENT_SHAPE.ELLIPSE) {
+                // Compute the ratio ry/rx (which is to be the same as for "closest-side")
+                const c =
+                    Math.min(Math.abs(y), Math.abs(y - bounds.height)) /
+                    Math.min(Math.abs(x), Math.abs(x - bounds.width));
+                const corner = findCorner(bounds, x, y, true);
+                rx = distance(corner.x - x, (corner.y - y) / c);
+                ry = c * rx;
+            }
+            break;
+
+        case 'farthest-side':
+            // Same as closest-side, except the ending shape is sized based on the farthest side(s)
+            if (shape === RADIAL_GRADIENT_SHAPE.CIRCLE) {
+                rx = ry = Math.max(
+                    Math.abs(x),
+                    Math.abs(x - bounds.width),
+                    Math.abs(y),
+                    Math.abs(y - bounds.height)
+                );
+            } else if (shape === RADIAL_GRADIENT_SHAPE.ELLIPSE) {
+                rx = Math.max(Math.abs(x), Math.abs(x - bounds.width));
+                ry = Math.max(Math.abs(y), Math.abs(y - bounds.height));
+            }
+            break;
+
+        case 'farthest-corner':
+            // Same as closest-corner, except the ending shape is sized based on the farthest corner.
+            // If the shape is an ellipse, the ending shape is given the same aspect ratio it would have if farthest-side were specified.
+            if (shape === RADIAL_GRADIENT_SHAPE.CIRCLE) {
+                rx = ry = Math.max(
+                    distance(x, y),
+                    distance(x, y - bounds.height),
+                    distance(x - bounds.width, y),
+                    distance(x - bounds.width, y - bounds.height)
+                );
+            } else if (shape === RADIAL_GRADIENT_SHAPE.ELLIPSE) {
+                // Compute the ratio ry/rx (which is to be the same as for "farthest-side")
+                const c =
+                    Math.max(Math.abs(y), Math.abs(y - bounds.height)) /
+                    Math.max(Math.abs(x), Math.abs(x - bounds.width));
+                const corner = findCorner(bounds, x, y, false);
+                rx = distance(corner.x - x, (corner.y - y) / c);
+                ry = c * rx;
+            }
+            break;
+
+        default:
+            // pixel or percentage values
+            rx = radius.x || 0;
+            ry = radius.y !== undefined ? radius.y : rx;
+            break;
+    }
+
+    return {
+        x: rx,
+        y: ry
+    };
+};
+
+export const transformWebkitRadialGradientArgs = (args: Array<string>): Array<string> => {
+    let shape = '';
+    let radius = '';
+    let extent = '';
+    let position = '';
+    let idx = 0;
+
+    const POSITION = /^(left|center|right|\d+(?:px|r?em|%)?)(?:\s+(top|center|bottom|\d+(?:px|r?em|%)?))?$/i;
+    const SHAPE_AND_EXTENT = /^(circle|ellipse)?\s*(closest-side|closest-corner|farthest-side|farthest-corner|contain|cover)?$/i;
+    const RADIUS = /^\d+(px|r?em|%)?(?:\s+\d+(px|r?em|%)?)?$/i;
+
+    const matchStartPosition = args[idx].match(POSITION);
+    if (matchStartPosition) {
+        idx++;
+    }
+
+    const matchShapeExtent = args[idx].match(SHAPE_AND_EXTENT);
+    if (matchShapeExtent) {
+        shape = matchShapeExtent[1] || '';
+        extent = matchShapeExtent[2] || '';
+        if (extent === 'contain') {
+            extent = 'closest-side';
+        } else if (extent === 'cover') {
+            extent = 'farthest-corner';
+        }
+        idx++;
+    }
+
+    const matchStartRadius = args[idx].match(RADIUS);
+    if (matchStartRadius) {
+        idx++;
+    }
+
+    const matchEndPosition = args[idx].match(POSITION);
+    if (matchEndPosition) {
+        idx++;
+    }
+
+    const matchEndRadius = args[idx].match(RADIUS);
+    if (matchEndRadius) {
+        idx++;
+    }
+
+    const matchPosition = matchEndPosition || matchStartPosition;
+    if (matchPosition && matchPosition[1]) {
+        position = matchPosition[1] + (/^\d+$/.test(matchPosition[1]) ? 'px' : '');
+        if (matchPosition[2]) {
+            position += ' ' + matchPosition[2] + (/^\d+$/.test(matchPosition[2]) ? 'px' : '');
+        }
+    }
+
+    const matchRadius = matchEndRadius || matchStartRadius;
+    if (matchRadius) {
+        radius = matchRadius[0];
+        if (!matchRadius[1]) {
+            radius += 'px';
+        }
+    }
+
+    if (position && !shape && !radius && !extent) {
+        radius = position;
+        position = '';
+    }
+
+    if (position) {
+        position = `at ${position}`;
+    }
+
+    return [[shape, extent, radius, position].filter(s => !!s).join(' ')].concat(args.slice(idx));
+};
+
+const transformObsoleteColorStops = (args: Array<string>): Array<string> => {
+    return (
+        args
+            .map(color => color.match(FROM_TO_COLORSTOP))
+            // $FlowFixMe
+            .map((v: Array<string>, index: number) => {
+                if (!v) {
+                    return args[index];
+                }
+
+                switch (v[1]) {
+                    case 'from':
+                        return `${v[4]} 0%`;
+                    case 'to':
+                        return `${v[4]} 100%`;
+                    case 'color-stop':
+                        if (v[3] === '%') {
+                            return `${v[4]} ${v[2]}`;
+                        }
+                        return `${v[4]} ${parseFloat(v[2]) * 100}%`;
+                }
+            })
+    );
+};
diff --git a/src/Length.js b/src/Length.js
index 6eb9e80..e2c1d78 100644
--- a/src/Length.js
+++ b/src/Length.js
@@ -1,6 +1,10 @@
 /* @flow */
 'use strict';
 
+import NodeContainer from './NodeContainer';
+
+const LENGTH_WITH_UNIT = /([\d.]+)(px|r?em|%)/i;
+
 export const LENGTH_TYPE = {
     PX: 0,
     PERCENTAGE: 1
@@ -34,3 +38,31 @@ export default class Length {
         return new Length(v);
     }
 }
+
+const getRootFontSize = (container: NodeContainer): number => {
+    const parent = container.parent;
+    return parent ? getRootFontSize(parent) : parseFloat(container.style.font.fontSize);
+};
+
+export const calculateLengthFromValueWithUnit = (
+    container: NodeContainer,
+    value: string,
+    unit: string
+): Length => {
+    switch (unit) {
+        case 'px':
+        case '%':
+            return new Length(value + unit);
+        case 'em':
+        case 'rem':
+            const length = new Length(value);
+            length.value *=
+                unit === 'em'
+                    ? parseFloat(container.style.font.fontSize)
+                    : getRootFontSize(container);
+            return length;
+        default:
+            // TODO: handle correctly if unknown unit is used
+            return new Length('0');
+    }
+};
diff --git a/src/Renderer.js b/src/Renderer.js
index 0cdfe7c..3e59702 100644
--- a/src/Renderer.js
+++ b/src/Renderer.js
@@ -14,7 +14,7 @@ import type {TextShadow} from './parsing/textShadow';
 import type {Matrix} from './parsing/transform';
 
 import type {BoundCurves} from './Bounds';
-import type {Gradient} from './Gradient';
+import type {LinearGradient, RadialGradient} from './Gradient';
 import type {ResourceStore, ImageElement} from './ResourceLoader';
 import type NodeContainer from './NodeContainer';
 import type StackingContext from './StackingContext';
@@ -22,7 +22,7 @@ import type {TextBounds} from './TextBounds';
 
 import {Bounds, parsePathForBorder, calculateContentBox, calculatePaddingBoxPath} from './Bounds';
 import {FontMetrics} from './Font';
-import {parseGradient} from './Gradient';
+import {parseGradient, GRADIENT_TYPE} from './Gradient';
 import TextContainer from './TextContainer';
 
 import {
@@ -62,7 +62,9 @@ export interface RenderTarget<Output> {
 
     render(options: RenderOptions): void,
 
-    renderLinearGradient(bounds: Bounds, gradient: Gradient): void,
+    renderLinearGradient(bounds: Bounds, gradient: LinearGradient): void,
+
+    renderRadialGradient(bounds: Bounds, gradient: RadialGradient): void,
 
     renderRepeat(
         path: Path,
@@ -265,9 +267,18 @@ export default class Renderer {
             backgroundImageSize.height
         );
 
-        const gradient = parseGradient(background.source, gradientBounds);
+        const gradient = parseGradient(container, background.source, gradientBounds);
         if (gradient) {
-            this.target.renderLinearGradient(gradientBounds, gradient);
+            switch (gradient.type) {
+                case GRADIENT_TYPE.LINEAR_GRADIENT:
+                    // $FlowFixMe
+                    this.target.renderLinearGradient(gradientBounds, gradient);
+                    break;
+                case GRADIENT_TYPE.RADIAL_GRADIENT:
+                    // $FlowFixMe
+                    this.target.renderRadialGradient(gradientBounds, gradient);
+                    break;
+            }
         }
     }
 
diff --git a/src/Util.js b/src/Util.js
index fd414de..7d390e8 100644
--- a/src/Util.js
+++ b/src/Util.js
@@ -3,6 +3,8 @@
 
 export const contains = (bit: number, value: number): boolean => (bit & value) !== 0;
 
+export const distance = (a: number, b: number): number => Math.sqrt(a * a + b * b);
+
 export const copyCSSStyles = (style: CSSStyleDeclaration, target: HTMLElement): HTMLElement => {
     // Edge does not provide value for cssText
     for (let i = style.length - 1; i >= 0; i--) {
diff --git a/src/renderer/CanvasRenderer.js b/src/renderer/CanvasRenderer.js
index ee5c3f4..781e60c 100644
--- a/src/renderer/CanvasRenderer.js
+++ b/src/renderer/CanvasRenderer.js
@@ -13,12 +13,23 @@ import type {Matrix} from '../parsing/transform';
 
 import type {Bounds} from '../Bounds';
 import type {ImageElement} from '../ResourceLoader';
-import type {Gradient} from '../Gradient';
+import type {LinearGradient, RadialGradient} from '../Gradient';
 import type {TextBounds} from '../TextBounds';
 
 import {PATH} from '../drawing/Path';
 import {TEXT_DECORATION_LINE} from '../parsing/textDecoration';
 
+const addColorStops = (
+    gradient: LinearGradient | RadialGradient,
+    canvasGradient: CanvasGradient
+): void => {
+    const maxStop = Math.max.apply(null, gradient.colorStops.map(colorStop => colorStop.stop));
+    const f = 1 / Math.max(1, maxStop);
+    gradient.colorStops.forEach(colorStop => {
+        canvasGradient.addColorStop(f * colorStop.stop, colorStop.color.toString());
+    });
+};
+
 export default class CanvasRenderer implements RenderTarget<HTMLCanvasElement> {
     canvas: HTMLCanvasElement;
     ctx: CanvasRenderingContext2D;
@@ -131,7 +142,7 @@ export default class CanvasRenderer implements RenderTarget<HTMLCanvasElement> {
         this.ctx.fillRect(x, y, width, height);
     }
 
-    renderLinearGradient(bounds: Bounds, gradient: Gradient) {
+    renderLinearGradient(bounds: Bounds, gradient: LinearGradient) {
         const linearGradient = this.ctx.createLinearGradient(
             bounds.left + gradient.direction.x1,
             bounds.top + gradient.direction.y1,
@@ -139,14 +150,43 @@ export default class CanvasRenderer implements RenderTarget<HTMLCanvasElement> {
             bounds.top + gradient.direction.y0
         );
 
-        gradient.colorStops.forEach(colorStop => {
-            linearGradient.addColorStop(colorStop.stop, colorStop.color.toString());
-        });
-
+        addColorStops(gradient, linearGradient);
         this.ctx.fillStyle = linearGradient;
         this.ctx.fillRect(bounds.left, bounds.top, bounds.width, bounds.height);
     }
 
+    renderRadialGradient(bounds: Bounds, gradient: RadialGradient) {
+        const x = bounds.left + gradient.center.x;
+        const y = bounds.top + gradient.center.y;
+
+        const radialGradient = this.ctx.createRadialGradient(x, y, 0, x, y, gradient.radius.x);
+        if (!radialGradient) {
+            return;
+        }
+
+        addColorStops(gradient, radialGradient);
+        this.ctx.fillStyle = radialGradient;
+
+        if (gradient.radius.x !== gradient.radius.y) {
+            // transforms for elliptical radial gradient
+            const midX = bounds.left + 0.5 * bounds.width;
+            const midY = bounds.top + 0.5 * bounds.height;
+            const f = gradient.radius.y / gradient.radius.x;
+            const invF = 1 / f;
+
+            this.transform(midX, midY, [1, 0, 0, f, 0, 0], () =>
+                this.ctx.fillRect(
+                    bounds.left,
+                    invF * (bounds.top - midY) + midY,
+                    bounds.width,
+                    bounds.height * invF
+                )
+            );
+        } else {
+            this.ctx.fillRect(bounds.left, bounds.top, bounds.width, bounds.height);
+        }
+    }
+
     renderRepeat(
         path: Path,
         image: ImageElement,
diff --git a/src/renderer/RefTestRenderer.js b/src/renderer/RefTestRenderer.js
index fa77189..0d5371e 100644
--- a/src/renderer/RefTestRenderer.js
+++ b/src/renderer/RefTestRenderer.js
@@ -18,7 +18,7 @@ import type {Matrix} from '../parsing/transform';
 
 import type {Bounds} from '../Bounds';
 import type {ImageElement} from '../ResourceLoader';
-import type {Gradient} from '../Gradient';
+import type {LinearGradient, RadialGradient} from '../Gradient';
 import type {TextBounds} from '../TextBounds';
 
 import {TEXT_DECORATION_STYLE, TEXT_DECORATION_LINE} from '../parsing/textDecoration';
@@ -110,7 +110,7 @@ class RefTestRenderer implements RenderTarget<string> {
         return `Path (${string})`;
     }
 
-    renderLinearGradient(bounds: Bounds, gradient: Gradient) {
+    renderLinearGradient(bounds: Bounds, gradient: LinearGradient) {
         const direction = [
             `x0: ${Math.round(gradient.direction.x0)}`,
             `x1: ${Math.round(gradient.direction.x1)}`,
@@ -129,6 +129,19 @@ class RefTestRenderer implements RenderTarget<string> {
         );
     }
 
+    renderRadialGradient(bounds: Bounds, gradient: RadialGradient) {
+        const stops = gradient.colorStops.map(
+            stop => `${stop.color.toString()} ${Math.ceil(stop.stop * 100) / 100}`
+        );
+
+        this.writeLine(
+            `RadialGradient: ${this.formatBounds(bounds)} radial-gradient(${gradient.radius
+                .x} ${gradient.radius.y} at ${gradient.center.x} ${gradient.center.y}, ${stops.join(
+                ', '
+            )})`
+        );
+    }
+
     renderRepeat(
         path: Path,
         image: ImageElement,
diff --git a/tests/node/gradient.js b/tests/node/gradient.js
new file mode 100644
index 0000000..3b2353b
--- /dev/null
+++ b/tests/node/gradient.js
@@ -0,0 +1,151 @@
+const Gradient = require('../../dist/npm/Gradient');
+const assert = require('assert');
+
+describe('Gradient', () => {
+    describe('transformWebkitRadialGradientArgs', () => {
+        it('white, black', () => {
+            assert.equal(Gradient.transformWebkitRadialGradientArgs(['white', 'black'])[0], '');
+        });
+
+        it('circle, white, black', () => {
+            assert.equal(
+                Gradient.transformWebkitRadialGradientArgs(['circle', 'white', 'black'])[0],
+                'circle'
+            );
+        });
+
+        it('10% 30%, white, black', () => {
+            assert.equal(
+                Gradient.transformWebkitRadialGradientArgs(['10% 30%', 'white', 'black'])[0],
+                '10% 30%'
+            );
+        });
+
+        it('30% 30%, closest-corner, white, black', () => {
+            assert.equal(
+                Gradient.transformWebkitRadialGradientArgs([
+                    '30% 30%',
+                    'closest-corner',
+                    'white',
+                    'black'
+                ])[0],
+                'closest-corner at 30% 30%'
+            );
+        });
+
+        it('30% 30%, circle closest-corner, white, black', () => {
+            assert.equal(
+                Gradient.transformWebkitRadialGradientArgs([
+                    '30% 30%',
+                    'circle closest-corner',
+                    'white',
+                    'black'
+                ])[0],
+                'circle closest-corner at 30% 30%'
+            );
+        });
+
+        it('center, 5em 40px, white, black', () => {
+            assert.equal(
+                Gradient.transformWebkitRadialGradientArgs([
+                    'center',
+                    '5em 40px',
+                    'white',
+                    'black'
+                ])[0],
+                '5em 40px at center'
+            );
+        });
+
+        it('45 45, 10, 52 50, 30, from(#A7D30C), to(red)', () => {
+            assert.equal(
+                Gradient.transformWebkitRadialGradientArgs([
+                    '45 45',
+                    '10',
+                    '52 50',
+                    '30',
+                    'from(#A7D30C)'
+                ])[0],
+                '30px at 52px 50px'
+            );
+        });
+
+        it('75% 19%, ellipse closest-side, #ababab, #0000ff 33%,#991f1f 100%', () => {
+            assert.equal(
+                Gradient.transformWebkitRadialGradientArgs([
+                    '75% 19%',
+                    'ellipse closest-side',
+                    '#ababab',
+                    '#0000ff 33%',
+                    '#991f1f 100%'
+                ])[0],
+                'ellipse closest-side at 75% 19%'
+            );
+        });
+
+        it('75% 19%, circle contain, #ababab, #0000ff 33%,#991f1f 100%', () => {
+            assert.equal(
+                Gradient.transformWebkitRadialGradientArgs([
+                    '75% 19%',
+                    'circle contain',
+                    '#ababab',
+                    '#0000ff 33%',
+                    '#991f1f 100%'
+                ])[0],
+                'circle closest-side at 75% 19%'
+            );
+        });
+
+        it('75% 19%, circle cover, #ababab, #0000ff 33%,#991f1f 100%', () => {
+            assert.equal(
+                Gradient.transformWebkitRadialGradientArgs([
+                    '75% 19%',
+                    'circle cover',
+                    '#ababab',
+                    '#0000ff 33%',
+                    '#991f1f 100%'
+                ])[0],
+                'circle farthest-corner at 75% 19%'
+            );
+        });
+
+        it('right 19%, ellipse cover, #ababab, #0000ff 33%,#991f1f 100%', () => {
+            assert.equal(
+                Gradient.transformWebkitRadialGradientArgs([
+                    'right 19%',
+                    'ellipse cover',
+                    '#ababab',
+                    '#0000ff 33%',
+                    '#991f1f 100%'
+                ])[0],
+                'ellipse farthest-corner at right 19%'
+            );
+        });
+
+        it('left 19%, ellipse cover, #ababab, #0000ff 33%,#991f1f 100%', () => {
+            assert.equal(
+                Gradient.transformWebkitRadialGradientArgs([
+                    'left 19%',
+                    'ellipse cover',
+                    '#ababab',
+                    '#0000ff 33%',
+                    '#991f1f 100%'
+                ])[0],
+                'ellipse farthest-corner at left 19%'
+            );
+        });
+
+        it('left top, circle cover, #ababab, #0000ff 33%,#991f1f 100%', () => {
+            assert.equal(
+                Gradient.transformWebkitRadialGradientArgs([
+                    'left top',
+                    'circle cover',
+                    '#ababab',
+                    '#0000ff 33%',
+                    '#991f1f 100%'
+                ])[0],
+                'circle farthest-corner at left top'
+            );
+        });
+    });
+});
diff --git a/tests/reftests/background/radial-gradient2.html b/tests/reftests/background/radial-gradient2.html
new file mode 100644
index 0000000..059f474
--- /dev/null
+++ b/tests/reftests/background/radial-gradient2.html
@@ -0,0 +1,51 @@
+<!doctype html>
+<html>
+	<head>
+		<title>Background attribute tests</title>
+        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+        <script type="text/javascript" src="../../test.js"></script>
+
+		<style>
+			div {
+				display: inline-block;
+				width: 100px;
+				height: 100px;
+				padding: 10px;
+				margin: 5px;
+				border: 15px solid black;
+				vertical-align: top;
+			}
+		</style>
+	</head>
+	<body>
+		<div style="background: radial-gradient(red, blue)"></div>
+		<div style="background: radial-gradient(circle, red, blue)"></div>
+		<div style="background: radial-gradient(ellipse, red, blue)"></div>
+		<div style="background: radial-gradient(circle, red, blue); width:200px"></div>
+		<div style="background: radial-gradient(ellipse, red, blue); width:200px"></div>
+		<div style="background: radial-gradient(closest-side, red, blue)"></div>
+		<div style="background: radial-gradient(closest-corner, red, blue)"></div>
+		<div style="background: radial-gradient(farthest-side, red, blue)"></div>
+		<div style="background: radial-gradient(farthest-corner, red, blue)"></div>
+		<div style="background: radial-gradient(circle 20px, red, blue)"></div>
+		<div style="background: radial-gradient(ellipse 20px 30px, red, blue)"></div>
+		<div style="font-size: 24px; border: none; padding: 0; width: auto;">
+			<div style="background: radial-gradient(circle 20px at 2em 80px, red, blue)"></div>
+			<div style="background: radial-gradient(circle 20px at 6rem 80px, red, blue)"></div>
+		</div>
+		<div style="background: radial-gradient(circle farthest-side, red, blue)"></div>
+		<div style="background: radial-gradient(at 20px 20px, red, blue)"></div>
+		<div style="background: radial-gradient(ellipse farthest-corner at 45px 45px , #00FFFF 0%, rgba(0, 0, 255, 0) 50%, #0000FF 95%);"></div>
+		<div style="background: radial-gradient(16px at 70px 50% , #000000 0%, #000000 14px, rgba(0, 0, 0, 0.3) 18px, rgba(0, 0, 0, 0) 19px);"></div>
+		<div style="background: radial-gradient(16px at 70px 50% , #000000 0%, #000000 87.5%, rgba(0, 0, 0, 0.3) 112.5%, rgba(0, 0, 0, 0) 118.75%);"></div>
+		<div style="background: radial-gradient(19px at 70px 50% , #000000 0%, #000000 73.68%, rgba(0, 0, 0, 0.3) 94.74%, rgba(0, 0, 0, 0) 100%);"></div>
+		<div style="background: radial-gradient(ellipse 60px 30px at 70px 50%, #000000 0%, blue 10%, yellow 15%, red 18%);"></div>
+		<div style="background: radial-gradient(circle farthest-corner at left top, aquamarine, deeppink);"></div>
+		<div style="width: 300px; height: 200px; transform: translate(20px, 30px) rotate(20deg)">
+			<div style="width: 200px; height: 150px; transform: translate(-10px, -20px) rotate(10deg)">
+				<div style="background: radial-gradient(circle, red, blue)"></div>
+				<div style="background: radial-gradient(ellipse, red, blue); width:150px;"></div>
+			</div>
+		</div>
+	</body>
+</html>

From 44eedcbc6552407dbbdefb6f7f206999e969dcb8 Mon Sep 17 00:00:00 2001
From: MoyuScript <i@moyu.moe>
Date: Tue, 12 Dec 2017 20:57:48 +0800
Subject: [PATCH 2/2] Radial gradient support

---
 CHANGELOG.md     |  1 +
 docs/features.md |  2 +-
 src/Gradient.js  | 74 ++++++++++++++++++++++++++++++------------------
 3 files changed, 49 insertions(+), 28 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index aec615b..2c85496 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
 ### Changelog ###
 
 #### v1.0.0-alpha4 - TBD ####
+ * Add support for radial-gradients
  * Fix logging option (#1302)
  * Add support for rendering webgl canvas content (#646)
  * Fix external SVG loading with proxies (#802)
diff --git a/docs/features.md b/docs/features.md
index b6af7e1..7c0cd74 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -11,6 +11,7 @@ Below is a list of all the supported CSS properties and values.
    - background-image
        - url()
        - linear-gradient()
+       - radial-gradient()
    - background-origin 
    - background-position
    - background-size   
@@ -68,7 +69,6 @@ These CSS properties are **NOT** currently supported
  - [filter](https://github.com/niklasvh/html2canvas/issues/493)
  - [font-variant-ligatures](https://github.com/niklasvh/html2canvas/pull/1085)
  - [list-style](https://github.com/niklasvh/html2canvas/issues/177)
- - radial-gradient()
  - [repeating-linear-gradient()](https://github.com/niklasvh/html2canvas/issues/1162)
  - word-break
  - [writing-mode](https://github.com/niklasvh/html2canvas/issues/1258)
diff --git a/src/Gradient.js b/src/Gradient.js
index 78aa405..a197dbd 100644
--- a/src/Gradient.js
+++ b/src/Gradient.js
@@ -32,19 +32,10 @@ export type ColorStop = {
     stop: number
 };
 
-export type LinearGradient = {
+export interface Gradient {
     type: GradientType,
-    direction: Direction,
     colorStops: Array<ColorStop>
-};
-
-export type RadialGradient = {
-    type: GradientType,
-    shape: RadialGradientShapeType,
-    center: Point,
-    radius: Point,
-    colorStops: Array<ColorStop>
-};
+}
 
 export const GRADIENT_TYPE = {
     LINEAR_GRADIENT: 0,
@@ -68,11 +59,44 @@ const LENGTH_FOR_POSITION = {
     bottom: new Length('100%')
 };
 
+export class LinearGradient implements Gradient {
+    type: GradientType;
+    colorStops: Array<ColorStop>;
+    direction: Direction;
+
+    constructor(colorStops: Array<ColorStop>, direction: Direction) {
+        this.type = GRADIENT_TYPE.LINEAR_GRADIENT;
+        this.colorStops = colorStops;
+        this.direction = direction;
+    }
+}
+
+export class RadialGradient implements Gradient {
+    type: GradientType;
+    colorStops: Array<ColorStop>;
+    shape: RadialGradientShapeType;
+    center: Point;
+    radius: Point;
+
+    constructor(
+        colorStops: Array<ColorStop>,
+        shape: RadialGradientShapeType,
+        center: Point,
+        radius: Point
+    ) {
+        this.type = GRADIENT_TYPE.RADIAL_GRADIENT;
+        this.colorStops = colorStops;
+        this.shape = shape;
+        this.center = center;
+        this.radius = radius;
+    }
+}
+
 export const parseGradient = (
     container: NodeContainer,
     {args, method, prefix}: BackgroundSource,
     bounds: Bounds
-): ?LinearGradient | RadialGradient => {
+): ?Gradient => {
     if (method === 'linear-gradient') {
         return parseLinearGradient(args, bounds, !!prefix);
     } else if (method === 'gradient' && args[0] === 'linear') {
@@ -83,10 +107,11 @@ export const parseGradient = (
             !!prefix
         );
     } else if (method === 'radial-gradient') {
-        if (prefix === '-webkit-') {
-            args = transformWebkitRadialGradientArgs(args);
-        }
-        return parseRadialGradient(container, args, bounds);
+        return parseRadialGradient(
+            container,
+            prefix === '-webkit-' ? transformWebkitRadialGradientArgs(args) : args,
+            bounds
+        );
     } else if (method === 'gradient' && args[0] === 'radial') {
         return parseRadialGradient(
             container,
@@ -176,11 +201,7 @@ const parseLinearGradient = (
         bounds.height * 2
     );
 
-    return {
-        type: GRADIENT_TYPE.LINEAR_GRADIENT,
-        direction,
-        colorStops: parseColorStops(args, firstColorStopIndex, lineLength)
-    };
+    return new LinearGradient(parseColorStops(args, firstColorStopIndex, lineLength), direction);
 };
 
 const parseRadialGradient = (
@@ -238,13 +259,12 @@ const parseRadialGradient = (
         bounds
     );
 
-    return {
-        type: GRADIENT_TYPE.RADIAL_GRADIENT,
+    return new RadialGradient(
+        parseColorStops(args, m ? 1 : 0, Math.min(gradientRadius.x, gradientRadius.y)),
         shape,
-        center: gradientCenter,
-        radius: gradientRadius,
-        colorStops: parseColorStops(args, m ? 1 : 0, Math.min(gradientRadius.x, gradientRadius.y))
-    };
+        gradientCenter,
+        gradientRadius
+    );
 };
 
 const calculateGradientDirection = (radian: number, bounds: Bounds): Direction => {